Compare commits

..

50 Commits

Author SHA1 Message Date
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
304 changed files with 42436 additions and 1525 deletions
-2
View File
@@ -1,2 +0,0 @@
'Get-Content' is not recognized as an internal or external command,
operable program or batch file.
+1 -1
View File
@@ -187,7 +187,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: Download web/dist
uses: actions/download-artifact@v4
+33 -26
View File
@@ -4,10 +4,12 @@
# 产出:
# 1. 前端 + 后端编译验证(go vet / go test / go build)
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64)
# 3. ghcr.io/{owner}/mebox 多架构 Docker 镜像(linux/amd64 + linux/arm64),
# 标签:beta(滚动)+ {基版本}-beta.{短 SHA}(本次构建,只增不改)
#
# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、
# 不打 release tag,只构建带 -beta 标识的产物供测试。
# 与 main 分支发布流(Auto-docker-publish.yml)的关系:镜像的构建方式、标签策略
# 和基版本号来源(version 分支)与发布流保持一致,区别是 beta 不做版本递增、
# 不打 release tag、不发布 GitHub Release、不部署服务器。
name: Beta Build
@@ -22,9 +24,6 @@ permissions:
contents: read
packages: write
env:
BETA_VERSION_PREFIX: beta
jobs:
# ─────────────────────────────────────────────────────────────────────────────
# 1) 编译验证 + 多平台二进制产物
@@ -32,17 +31,24 @@ jobs:
test-and-build:
name: Test & build artifacts
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
binary_version: ${{ steps.version.outputs.binary_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# 基版本号与发布流同源:读 version 分支上由 main 发布流水线维护的 VERSION。
# beta 只读不自增,避免与 main 的发布流程争抢版本号。
- name: Resolve beta version
id: version
run: |
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
git fetch origin version || true
BASE_VERSION=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7}
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
echo "version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
echo "binary_version=mebox-v${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
# The binary embeds the SPA (web/dist) via go:embed, so dist must exist
# before the Go toolchain touches the web package.
@@ -59,7 +65,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: go vet
@@ -71,13 +77,13 @@ jobs:
- name: go build (host)
run: go build ./...
# 多平台可执行文件(嵌入刚构建的 web/dist)
# 多平台可执行文件(嵌入刚构建的 web/dist),版本号写入口径与发布流一致
- name: Build linux/amd64
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
- name: Build linux/arm64
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
- name: Build windows/amd64
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -87,7 +93,8 @@ jobs:
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────────
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta)
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta + :{基版本}-beta.{短 SHA})
# 构建步骤与 Auto-docker-publish.yml 的 build-image 一致,只是不带 deploy。
# ─────────────────────────────────────────────────────────────────────────────
docker-beta:
name: Build & push beta Docker image
@@ -98,13 +105,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Resolve beta version
id: version
run: |
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7}
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
@@ -115,6 +115,15 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/mebox
tags: |
type=raw,value=beta
type=raw,value=${{ needs.test-and-build.outputs.version }}
- name: Build & push
uses: docker/build-push-action@v6
with:
@@ -123,11 +132,9 @@ jobs:
push: true
provenance: false
sbom: false
tags: ghcr.io/${{ github.repository_owner }}/mebox:beta
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.repository }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.version.outputs.full_version }}
VERSION=${{ needs.test-and-build.outputs.binary_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
+2 -1
View File
@@ -80,4 +80,5 @@ media/
tools/
verify-cache/
verify-data/
.zcode/
.zcode/
.tmp-src
+114
View File
@@ -0,0 +1,114 @@
# Binaries
bin/
*.exe
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
*.out
# Go workspace
go.work
# Dependency directories
node_modules/
# Build artifacts
web/dist/
web/.vite/
web/coverage/
web/tsconfig.tsbuildinfo
dist-release/
# Data / runtime
data/
cache/
logs/
.tmp-deploy-data/
.tmp-deploy-smoke-data/
.tmp-deploy-smoke-cache/
.tmp-deploy-cache/
.tmp-deploy-server.*
.tmp-live-server.*
.mebox.pid
*.log
*.db
*.db-journal
*.db-shm
*.db-wal
# Editor / OS
.idea/
.vscode/
.DS_Store
Thumbs.db
# Env files
.env
.env.local
.env.*.local
# Local configs (keep examples)
config/secrets.yaml
config.yaml
# WorkBuddy workspace (local AI assistant memory)
.workbuddy/
# Editor backups
*~
.tmp_*
# Runtime / local-only artifacts (清理补充)
.tmp/
.tmp-live-backups/
.tmp-*
.codex-*
.codex/
downloads/
media/
*.pid
# 本地开发运行产物
.agents/
.claude/
.dev-cache/
.dev-data/
.dev-logs/
tools/
verify-cache/
verify-data/
.zcode/
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
.git/
.hg/
.svn/
bower_components/
jspm_packages/
__pycache__/
site-packages/
venv/
coverage/
htmlcov/
lcov-report/
cmakefiles/
cmake-build-*/
bazel-*/
pods/
deriveddata/
storybook-static/
playwright-report/
test-results/
allure-results/
allure-report/
cdk.out/
*.egg-info/
*.dist-info/
eggs/
pip-wheel-metadata/
wheels/
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
# 自定义规则写在下方(本行提示可删除)
+2 -2
View File
@@ -3,7 +3,7 @@
# Multi-architecture build for MeBox.
#
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
# Stage 2 (backend) : Go 1.26 -> single static binary (CGO_ENABLED=0)
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
#
# Build:
@@ -25,7 +25,7 @@ COPY web/ .
RUN npm run build
# ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) -------------------
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend
ARG TARGETOS
ARG TARGETARCH
ARG GOPROXY=https://proxy.golang.org,direct
+1 -1
View File
@@ -22,7 +22,7 @@
</p>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="Go" src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
+1 -1
View File
@@ -21,7 +21,7 @@
</p>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="Go" src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
+206
View File
@@ -0,0 +1,206 @@
// reader-smoke 是阅读书源兼容性冒烟工具:
// 对批量书源逐个跑「搜索 → 详情 → 目录 → 正文」全链路,输出兼容率报告。
//
// 用法:
//
// go run ./cmd/reader-smoke -file sources.json -key 斗破苍穹 -c 8
// go run ./cmd/reader-smoke -url https://example.com/sources.json -json > report.json
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/reader"
)
func main() {
file := flag.String("file", "", "书源文件路径(JSON 数组/对象/Base64/每行一个)")
urlFlag := flag.String("url", "", "书源网络地址(与 -file 二选一)")
key := flag.String("key", "斗破苍穹", "搜索关键词")
concurrency := flag.Int("c", 4, "并发数")
timeout := flag.Int("timeout", 90, "单源全链路超时(秒)")
jsonOut := flag.Bool("json", false, "输出完整 JSON 报告(追加在汇总后)")
flag.Parse()
payload := ""
switch {
case *file != "":
data, err := os.ReadFile(*file)
if err != nil {
fatal("读取文件失败: %v", err)
}
payload = string(data)
case *urlFlag != "":
client := helper.NewSiteHTTPClient(30, true)
req, err := http.NewRequest("GET", *urlFlag, nil)
if err != nil {
fatal("构造请求失败: %v", err)
}
for k, v := range helper.HTTPHeaderPresets() {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
fatal("拉取书源失败: %v", err)
}
defer resp.Body.Close()
var sb strings.Builder
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
sb.Write(buf[:n])
if err != nil {
break
}
}
payload = sb.String()
default:
fatal("需要 -file 或 -url 指定书源来源")
}
sources := reader.ParseSourcePayload(payload)
if len(sources) == 0 {
fatal("未从输入中识别到书源")
}
// 书源文件缓存根目录:java.downloadFile / cacheFile 需要它才能落盘,
// 否则「拷贝轻小说」这类把整卷文本缓存到本地再读的源会被判为不支持。
// 用固定子目录而不是每次 MkdirTemp,避免反复运行在临时目录里留一堆垃圾。
cfg := &config.Config{}
cacheDir := filepath.Join(os.TempDir(), "reader-smoke-cache")
if err := os.MkdirAll(cacheDir, 0o750); err == nil {
cfg.Cache.CacheDir = cacheDir
}
svc := reader.NewReaderService(cfg, zap.NewNop(), &repository.Container{})
ctx := context.Background()
results := make([]*reader.SmokeChainResult, len(sources))
sem := make(chan struct{}, max(1, *concurrency))
var wg sync.WaitGroup
for i, raw := range sources {
wg.Add(1)
sem <- struct{}{}
go func(i int, raw string) {
defer wg.Done()
defer func() { <-sem }()
ctxSrc, cancel := context.WithTimeout(ctx, time.Duration(*timeout)*time.Second)
defer cancel()
res := svc.SmokeSource(ctxSrc, raw, *key)
results[i] = res
status := "✓"
if !res.OK {
status = "✗"
}
fmt.Fprintf(os.Stderr, "%s %-24s [%s] hits=%d chapters=%d content=%d %s\n",
status, truncate(res.SourceName, 24), stageCN(res), res.SearchHits, res.Chapters, res.ContentLen, res.Error)
}(i, raw)
}
wg.Wait()
// 汇总
var searchOK, infoOK, tocOK, contentOK, allOK int
failedAt := map[string]int{}
for _, r := range results {
if r == nil {
continue
}
switch r.FailedAt {
case "":
allOK++
searchOK++
infoOK++
tocOK++
contentOK++
case "search":
failedAt["search"]++
case "info":
searchOK++
failedAt["info"]++
case "toc":
searchOK++
infoOK++
failedAt["toc"]++
case "content":
searchOK++
infoOK++
tocOK++
failedAt["content"]++
}
}
n := len(results)
pct := func(v int) string {
if n == 0 {
return "0%"
}
return fmt.Sprintf("%.1f%%", float64(v)/float64(n)*100)
}
fmt.Printf("\n==== 冒烟报告 ====\n")
fmt.Printf("书源总数: %d 关键词: %s\n", n, *key)
fmt.Printf("搜索通过: %d (%s)\n", searchOK, pct(searchOK))
fmt.Printf("详情通过: %d (%s)\n", infoOK, pct(infoOK))
fmt.Printf("目录通过: %d (%s)\n", tocOK, pct(tocOK))
fmt.Printf("正文通过: %d (%s)\n", contentOK, pct(contentOK))
fmt.Printf("全链路通过: %d (%s)\n", allOK, pct(allOK))
for _, stage := range []string{"search", "info", "toc", "content"} {
if failedAt[stage] > 0 {
fmt.Printf(" 失败于 %s: %d\n", stageCN(&reader.SmokeChainResult{FailedAt: stage}), failedAt[stage])
}
}
if *jsonOut {
out, err := json.MarshalIndent(results, "", " ")
if err != nil {
fatal("序列化报告失败: %v", err)
}
fmt.Println(string(out))
}
}
func stageCN(r *reader.SmokeChainResult) string {
switch r.FailedAt {
case "":
return "完成"
case "search":
return "搜索"
case "info":
return "详情"
case "toc":
return "目录"
case "content":
return "正文"
case "parse":
return "解析"
default:
return r.FailedAt
}
}
func truncate(s string, n int) string {
rs := []rune(strings.TrimSpace(s))
if len(rs) <= n {
if len(rs) == 0 {
return "(未命名)"
}
return string(rs)
}
return string(rs[:n]) + "…"
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "reader-smoke: "+format+"\n", args...)
os.Exit(1)
}
+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>
+144
View File
@@ -0,0 +1,144 @@
# 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`(正文/目录/搜索缓存,内存+Redis)、`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,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 |
| P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 |
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/
+253
View File
@@ -0,0 +1,253 @@
# 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 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。
**书架分组(`/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,原生纵向滚动照常。
- 滚动模式的滚轮(`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+滚轮` 保留浏览器缩放。
- 目录:整屏面板(顶部返回 + 书名 + 章数,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,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).
+17 -5
View File
@@ -1,6 +1,6 @@
module github.com/truewhile/MeBox
go 1.25.0
go 1.26.0
require (
fyne.io/systray v1.12.2
@@ -19,9 +19,9 @@ require (
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.15
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.49.0
golang.org/x/crypto v0.55.0
golang.org/x/image v0.45.0
golang.org/x/sync v0.22.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.47.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
@@ -30,6 +30,12 @@ require (
)
require (
github.com/PaesslerAG/gval v1.0.0 // indirect
github.com/PaesslerAG/jsonpath v0.1.1 // indirect
github.com/PuerkitoBio/goquery v1.13.0 // indirect
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/antchfx/htmlquery v1.3.6 // indirect
github.com/antchfx/xpath v1.3.6 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -38,6 +44,9 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
@@ -47,9 +56,12 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -90,8 +102,8 @@ require (
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.70.0 // indirect
+97
View File
@@ -1,7 +1,20 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8=
github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk=
github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY=
github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM=
github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/antchfx/htmlquery v1.3.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9ag=
github.com/antchfx/htmlquery v1.3.6/go.mod h1:kcVUqancxPygm26X2rceEcagZFFVkLEE7xgLkGSDl/4=
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -24,6 +37,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 h1:MJFqtdxTq94XqUgg7DcGCaOIXrDTJE/tPHK66Jshguc=
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9 h1:Ksd3Vil12b/ykT5NjGzd3qNc688cJB9OUXJZZChEktI=
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 h1:48iGRx9HamDuG4pCbPG5IXt4bKHhgn33KGynzHUgeIA=
@@ -54,6 +73,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -62,7 +83,11 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -177,6 +202,7 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
@@ -191,33 +217,104 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+26 -16
View File
@@ -112,8 +112,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
t.Fatalf("status = %d, want 200", w.Code)
}
var cfg struct {
MergeSources bool `json:"merge_sources"`
PlaybackRate float64 `json:"playback_rate"`
MergeSources bool `json:"merge_sources"`
}
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
@@ -121,9 +120,6 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
if cfg.MergeSources {
t.Fatal("default merge preference should be false")
}
if cfg.PlaybackRate != 1 {
t.Fatalf("default playback rate = %v, want 1", cfg.PlaybackRate)
}
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
t.Fatalf("set: %v", err)
@@ -141,7 +137,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
svc := newDanmakuSettingsService(t)
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"playback_rate\":1.5,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
updateDanmakuSettingsHandler(svc)(c)
@@ -158,7 +154,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
t.Fatalf("unexpected render config: %+v", cfg)
}
if !cfg.MergeSources || cfg.Volume != 0.35 || cfg.PlaybackRate != 1.5 {
if !cfg.MergeSources || cfg.Volume != 0.35 {
t.Fatalf("unexpected user preferences: %+v", cfg)
}
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
@@ -169,7 +165,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err)
}
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.PlayerPlaybackRate != 1.5 || user.DanmakuSource != "https://dm.example/base" {
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
t.Fatalf("preferences not persisted: %+v", user)
}
}
@@ -207,22 +203,36 @@ func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
}
}
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
// 播放倍速只作用于当前视频:接口既不下发也不保存它。老客户端仍可能带上
// playback_rate,服务端应当忽略而不是报错。
func TestDanmakuSettingsIgnorePlaybackRate(t *testing.T) {
svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"playback_rate":4}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
getDanmakuConfigHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
if bytes.Contains(w.Body.Bytes(), []byte("playback_rate")) {
t.Fatalf("config must not expose playback rate: %s", w.Body.String())
}
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"volume":0.5,"playback_rate":1.5}`, "user-1")
updateDanmakuSettingsHandler(svc)(c2)
if w2.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w2.Code, w2.Body.String())
}
if bytes.Contains(w2.Body.Bytes(), []byte("playback_rate")) {
t.Fatalf("settings response must not expose playback rate: %s", w2.Body.String())
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read user: %v", err)
}
if user.PlayerPlaybackRate != 1 {
t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate)
if user.PlayerVolume != 0.5 {
t.Fatalf("volume not persisted: %v", user.PlayerVolume)
}
}
+36 -1
View File
@@ -24,7 +24,42 @@ func embyNextUpHandler(svc *service.Container) gin.HandlerFunc {
return
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
out, err := svc.Emby.NextUp(c.Request.Context(), userID, 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
+130 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
@@ -104,8 +105,8 @@ func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
}
if watched {
h := &model.PlaybackHistory{
UserID: userID, MediaID: m.ID, PositionMs: 1000, DurationMs: 2000,
WatchedAt: watchedAt, Completed: false,
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)
@@ -126,6 +127,49 @@ func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
}
}
// 回归:用户在剧集详情页点播放、只看了几秒就退出(历史行 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)
@@ -242,3 +286,87 @@ func TestEmbyNextUpRejectsForeignUserID(t *testing.T) {
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)
}
}
+17 -2
View File
@@ -132,7 +132,10 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
// ParentId / SeriesId 收窄到当前库或当前剧,避免详情页继续播放串到全站历史。
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid", "SeriesId", "seriesId", "seriesid")
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, parentID, limit, startIndex)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -228,12 +231,24 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
// 不一致时把结果过滤成空集。
seasonIndex = nil
}
// Emby 客户端按 StartIndex/Limit 分页拉取分集,并在已收条目数小于
// TotalRecordCount 时继续请求下一页。忽略这两个参数会让客户端永远
// 停在第一页、反复重发同一请求(现象是分集列表一直加载不出来)。
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "500"))
if limit <= 0 {
limit = 500
}
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
if startIndex < 0 {
startIndex = 0
}
params := service.ItemsParams{
UserID: embyEffectiveUserID(c),
ParentID: parentID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
Limit: 500,
Limit: limit,
StartIndex: startIndex,
SeasonIndex: seasonIndex,
}
out, err := svc.Emby.Items(c.Request.Context(), params)
+9 -7
View File
@@ -86,7 +86,9 @@ func embyPrewarmPlaybackTargets(svc *service.Container, c *gin.Context, out map[
if err != nil || m == nil {
return
}
raw := strings.TrimSpace(m.STRMURL)
// 与播放路径(StreamService.ServeFileWithCloudMode)同一套目标解析:
// STRMURL 为空时回读 .strm 文件内容,预热才能覆盖同一批条目。
raw := service.MediaSTRMTarget(m)
if raw == "" || !service.IsStrmMediaRow(m) {
return
}
@@ -409,9 +411,10 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
// 只需确认媒体行存在且对当前用户可见;Emby.Item 会构建完整条目载荷,
// 转码播放下每个分片请求都跑一遍太浪费。
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) || svc.Stream == nil {
c.Status(http.StatusNotFound)
return
}
@@ -436,9 +439,8 @@ func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) || svc.Stream == nil {
c.Status(http.StatusNotFound)
return
}
+3
View File
@@ -198,6 +198,9 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
auth.GET("/Shows/NextUp", embyNextUpHandler(svc))
auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc))
// 部分客户端用路径形式 /Shows/{seriesId}/NextUp,而不是 query SeriesId。
auth.GET("/Shows/:id/NextUp", embyShowNextUpHandler(svc))
auth.GET("/Users/:userId/Shows/:id/NextUp", embyShowNextUpHandler(svc))
auth.GET("/MediaSegments/:id", embyMediaSegmentsHandler(svc))
auth.GET("/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
auth.GET("/Users/:userId/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
@@ -42,6 +42,8 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
auth.GET("/shows/nextup", embyNextUpHandler(svc))
auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc))
auth.GET("/shows/:id/nextup", embyShowNextUpHandler(svc))
auth.GET("/users/:userId/shows/:id/nextup", embyShowNextUpHandler(svc))
auth.GET("/mediasegments/:id", embyMediaSegmentsHandler(svc))
auth.GET("/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
auth.GET("/users/:userId/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -21,6 +22,31 @@ import (
// two seasons, so /Shows/{id}/Episodes can be exercised with the query forms
// real clients send.
func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
t.Helper()
return embySeriesRouter(t, []model.Media{
{
Base: model.Base{ID: "s1e1"},
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
Container: "mkv",
},
{
Base: model.Base{ID: "s2e1"},
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
SeasonNum: 2,
EpisodeNum: 1,
Container: "mkv",
},
})
}
// embySeriesRouter builds an Emby-compatible router with a single tv library and
// one series made of the given episode rows. Callers omit LibraryID: it is filled
// in here once the library exists.
func embySeriesRouter(t *testing.T, rows []model.Media) (*gin.Engine, string, string) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -48,27 +74,9 @@ func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for _, m := range []model.Media{
{
Base: model.Base{ID: "s1e1"},
LibraryID: lib.ID,
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
Container: "mkv",
},
{
Base: model.Base{ID: "s2e1"},
LibraryID: lib.ID,
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
SeasonNum: 2,
EpisodeNum: 1,
Container: "mkv",
},
} {
if err := db.Create(&m).Error; err != nil {
for i := range rows {
rows[i].LibraryID = lib.ID
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
@@ -104,7 +112,34 @@ func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
return router, secret, seriesID
}
func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
func fetchSeasonID(t *testing.T, router *gin.Engine, secret, seriesID string, index int) string {
t.Helper()
path := "/Shows/" + seriesID + "/Seasons"
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
}
var seasons struct {
Items []map[string]any `json:"Items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
t.Fatalf("decode seasons: %v", err)
}
for _, s := range seasons.Items {
if got, ok := s["IndexNumber"].(float64); ok && int(got) == index {
if id, _ := s["Id"].(string); id != "" {
return id
}
}
}
t.Fatalf("season %d not found in %#v", index, seasons.Items)
return ""
}
func fetchEpisodeItems(t *testing.T, router *gin.Engine, secret, path string) ([]map[string]any, int) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
@@ -120,12 +155,18 @@ func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]s
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
ids := make([]string, 0, len(payload.Items))
for _, item := range payload.Items {
return payload.Items, int(payload.TotalRecordCount)
}
func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
t.Helper()
items, total := fetchEpisodeItems(t, router, secret, path)
ids := make([]string, 0, len(items))
for _, item := range items {
id, _ := item["Id"].(string)
ids = append(ids, id)
}
return ids, int(payload.TotalRecordCount)
return ids, total
}
// TestEmbyShowEpisodesHonoursSeasonQueryParam is the client-facing regression
@@ -174,29 +215,7 @@ func TestEmbyShowEpisodesHonoursSeasonQueryParam(t *testing.T) {
func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
router, secret, seriesID := embySeasonEpisodesRouter(t)
seasonsPath := "/Shows/" + seriesID + "/Seasons"
req := httptest.NewRequest(http.MethodGet, seasonsPath, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
}
var seasons struct {
Items []map[string]any `json:"Items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
t.Fatalf("decode seasons: %v", err)
}
var season1ID string
for _, s := range seasons.Items {
if index, ok := s["IndexNumber"].(float64); ok && int(index) == 1 {
season1ID, _ = s["Id"].(string)
}
}
if season1ID == "" {
t.Fatalf("season 1 not found in %#v", seasons.Items)
}
season1ID := fetchSeasonID(t, router, secret, seriesID, 1)
ids, _ := fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID)
if len(ids) != 1 || ids[0] != "s1e1" {
@@ -209,3 +228,145 @@ func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
t.Fatalf("SeasonId+Season episodes = %#v, want [s1e1]", ids)
}
}
// foldedDuplicateSeriesRows models a season where two files resolve to the same
// episode number — 3月的狮子 S01E11 together with S01E11.5, which the episode
// parser reads as S01E11 and stores as a second row for season 1 episode 2 here.
func foldedDuplicateSeriesRows() []model.Media {
return []model.Media{
{
Base: model.Base{ID: "e1"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
Container: "mkv",
},
{
Base: model.Base{ID: "e2"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.mkv",
SeasonNum: 1,
EpisodeNum: 2,
Container: "mkv",
},
{
Base: model.Base{ID: "e2half"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.5.mkv",
SeasonNum: 1,
EpisodeNum: 2,
Container: "mkv",
},
}
}
// TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent is the regression test
// for the "第一季分集加载不出来" lock-up: two rows sharing one season/episode are
// folded into a single Emby item with two MediaSources. TotalRecordCount used to
// be computed before that fold, so clients that page until they have
// TotalRecordCount items kept re-requesting the same page and never rendered the
// season.
func TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent(t *testing.T) {
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
seasonID := fetchSeasonID(t, router, secret, seriesID, 1)
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+seasonID)
if total != len(items) {
t.Fatalf("TotalRecordCount = %d but %d items returned, want them equal", total, len(items))
}
if total != 2 {
t.Fatalf("TotalRecordCount = %d, want 2 distinct episodes", total)
}
// The folded episode must still carry both files so clients can switch version.
versioned := 0
for _, item := range items {
if sources, ok := item["MediaSources"].([]any); ok && len(sources) > 1 {
versioned++
}
}
if versioned != 1 {
t.Fatalf("items with multiple MediaSources = %d, want 1 (%#v)", versioned, items)
}
}
// TestEmbyShowEpisodesPagingAdvances pins that the client's StartIndex/Limit reach
// the episode list. Ignoring them made every page identical, so a client paging
// until it has TotalRecordCount items could never finish loading the season.
func TestEmbyShowEpisodesPagingAdvances(t *testing.T) {
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
base := "/Shows/" + seriesID + "/Episodes?Season=1"
first, total := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=0&Limit=1")
if total != 2 || len(first) != 1 {
t.Fatalf("page 1 = %#v (total=%d), want one item of two", first, total)
}
second, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=1&Limit=1")
if len(second) != 1 || second[0] == first[0] {
t.Fatalf("page 2 = %#v, want the remaining episode (page 1 = %#v)", second, first)
}
third, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=2&Limit=1")
if len(third) != 0 {
t.Fatalf("page 3 = %#v, want an empty page so clients stop paging", third)
}
}
// 半集(S01E11.5)在 Emby 侧必须和第 11 集并列返回:一旦被折成一个条目,
// TotalRecordCount 就会比 Items 多一条,按总数翻页的客户端会一直重发同一页
// (这就是「第一季分集加载不出来」的根因)。Emby 的 IndexNumber 只能是整数,
// 所以集号仍占 11,小数通过条目名体现。
func TestEmbyShowEpisodesKeepsHalfEpisodeVisible(t *testing.T) {
router, secret, seriesID := embySeriesRouter(t, []model.Media{
{
Base: model.Base{ID: "e11"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.mkv",
SeasonNum: 1,
EpisodeNum: 11,
Container: "mkv",
},
{
Base: model.Base{ID: "e11half"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.5.mkv",
SeasonNum: 1,
EpisodeNum: 11,
EpisodeFraction: 0.5,
Container: "mkv",
},
{
Base: model.Base{ID: "e12"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E12.mkv",
SeasonNum: 1,
EpisodeNum: 12,
Container: "mkv",
},
})
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?Season=1")
if total != 3 || len(items) != 3 {
t.Fatalf("TotalRecordCount = %d, items = %d, want 3/3", total, len(items))
}
wantOrder := []string{"e11", "e11half", "e12"}
for i, want := range wantOrder {
id, _ := items[i]["Id"].(string)
if id != want {
t.Fatalf("items[%d] = %q, want %q (order %#v)", i, id, want, items)
}
}
for _, item := range items {
sources, _ := item["MediaSources"].([]any)
if len(sources) != 1 {
t.Fatalf("item %v folded %d sources, want 1", item["Id"], len(sources))
}
}
halfName, _ := items[1]["Name"].(string)
if !strings.HasPrefix(halfName, "第 11.5 集") {
t.Fatalf("half episode name = %q, want prefix 第 11.5 集", halfName)
}
if idx, _ := items[1]["IndexNumber"].(float64); int(idx) != 11 {
t.Fatalf("half episode IndexNumber = %v, want 11 (Emby only supports integers)", items[1]["IndexNumber"])
}
}
+14
View File
@@ -26,6 +26,20 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
api.HEAD("/strm/play/:provider/:file", strmPlayHandler(svc))
// 阅读媒体代理:音频流/漫画图片,鉴权靠 HMAC 签名(audio/img 元素带不了 JWT)。
api.GET("/reader/media", readerMediaProxyHandler(svc))
// 本地书籍内嵌资源(EPUB 图片):同样靠 HMAC 签名鉴权。
api.GET("/reader/local/asset", readerLocalAssetHandler(svc))
// 本地有声书音频流:同样靠 HMAC 签名鉴权,服务端处理 Range。
api.GET("/reader/local/audio", readerLocalAudioHandler(svc))
api.HEAD("/reader/local/audio", readerLocalAudioHandler(svc))
// 需要转码的音轨(WMA 等浏览器解不了的格式):签名鉴权 + 服务端 ffmpeg 转码。
api.GET("/reader/audio/transcode", readerAudioTranscodeHandler(svc))
api.HEAD("/reader/audio/transcode", readerAudioTranscodeHandler(svc))
// 阅读书源浏览器页面与资源代理:java.startBrowserAwait 承载的页面填进
// <iframe src>,同样带不了 JWT,鉴权靠绑定待办 ID 的 HMAC 签名。
api.GET("/reader/browser/page", readerBrowserPageHandler(svc))
api.GET("/reader/browser/asset", readerBrowserAssetHandler(svc))
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
+12
View File
@@ -12,6 +12,8 @@ import (
"net/url"
"strings"
"go.uber.org/zap"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
@@ -173,10 +175,20 @@ func externalPlaybackToken(c *gin.Context, svc *service.Container, mediaID strin
uid, _ := c.Get(middleware.CtxUserID)
u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
if err != nil || u == nil {
if svc.Log != nil {
svc.Log.Warn("external playback token: user lookup failed",
zap.String("media_id", mediaID), zap.Error(err))
}
return ""
}
token, err := svc.Auth.IssueExternalPlaybackToken(u, mediaID, durationSec)
if err != nil {
// 静默返回空串会让 stream_url 变成 ?token=(空)、外部播放器只看到
// 401 无从排查,至少在服务端日志里留下原因。
if svc.Log != nil {
svc.Log.Warn("issue external playback token failed",
zap.String("media_id", mediaID), zap.Error(err))
}
return ""
}
return token
+3 -23
View File
@@ -22,9 +22,7 @@ import (
func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
autoSkip := resolveAutoSkipFlag(c, svc)
source := resolveSegmentSource(c, svc)
segments := []service.SegmentView{}
pending := false
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
@@ -33,22 +31,14 @@ func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
}
// 远程 Emby 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。
if svc.Segments != nil && !service.IsEmbyRemoteID(m.ID) {
result, listErr := svc.Segments.SegmentsForPlayback(c.Request.Context(), m, source)
rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m)
if listErr != nil && svc.Log != nil {
svc.Log.Debug("list media segments failed",
zap.String("media_id", m.ID), zap.Error(listErr))
}
segments = service.ToSegmentViews(result.Segments)
pending = result.Pending
segments = service.ToSegmentViews(rows)
}
// pending 告诉客户端「章节提取还在后台跑,过几秒再拉一次」;提取完成时
// 如果片头还没播完,跳过按钮就会自己出现,已经过了片头则不会提示。
c.JSON(http.StatusOK, gin.H{
"segments": segments,
"auto_skip": autoSkip,
"pending": pending,
"source": source,
})
c.JSON(http.StatusOK, gin.H{"segments": segments, "auto_skip": autoSkip})
}
}
@@ -63,13 +53,3 @@ func resolveAutoSkipFlag(c *gin.Context, svc *service.Container) bool {
}
return profile.SkipIntro
}
// resolveSegmentSource reads the「片头片尾数据来源」choice off the active profile.
// PIN-locked profiles fall back to auto rather than leaking the profile's setting.
func resolveSegmentSource(c *gin.Context, svc *service.Container) string {
profile, locked := selectedPlayProfile(c, svc)
if locked || profile == nil {
return service.SegmentSourceAuto
}
return service.NormalizeSegmentSource(profile.SegmentSource)
}
+247
View File
@@ -0,0 +1,247 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// 书源宿主浏览器的 HTTP 层测试:轮询待办 → 承载页面 → 回传 DOM。
//
// 这条链路对应真实书源的「切换线路」「用户后台」:
// java.startBrowserAwait 会阻塞在 POST /sources/:id/login 上,
// 前端必须能从 /reader/browser/pending 拿到页面,再把 DOM POST 回去。
// browserHandlerSource 构造一个用 startBrowserAwait 切换线路的书源。
func browserHandlerSource(sourceURL string) string {
loginJS := `function switchLine() {
let html = '<!DOCTYPE html><html><head></head><body><span id="serverValue">线路甲</span></body></html>';
let body = java.startBrowserAwait('data:text/html;base64,' + java.base64Encode(html), '线路设置', false).body();
let m = body.match(/id="serverValue"\s*>\s*([^<]*?)\s*<\/span>/);
source.setVariable(JSON.stringify({线路: m ? m[1] : ''}));
}`
src := map[string]any{
"bookSourceUrl": sourceURL,
"bookSourceName": "浏览器面板源",
"loginUrl": loginJS,
"loginUi": `[{"name":"切换线路","type":"button","action":"switchLine()"}]`,
}
out, _ := json.Marshal(src)
return string(out)
}
// registerBrowserRoutesForTest 挂载阅读路由 + 承载页面/资源代理(跳过鉴权)。
// 生产环境里后两个是公开路由(iframe 带不了 JWT),鉴权靠 HMAC 签名。
func registerBrowserRoutesForTest(cfg *service.Container) *gin.Engine {
r := registerReaderRoutesForTest(cfg)
r.GET("/api/reader/browser/page", readerBrowserPageHandler(cfg))
r.GET("/api/reader/browser/asset", readerBrowserAssetHandler(cfg))
return r
}
func TestReaderBrowserPanelRoutes(t *testing.T) {
container := newReaderHandlerContainer(t)
router := registerBrowserRoutesForTest(container)
// 导入书源
body, _ := json.Marshal(map[string]string{"text": browserHandlerSource("https://panel.example.com")})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("导入书源 status=%d body=%s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
var list struct {
Sources []struct {
ID string `json:"id"`
} `json:"sources"`
}
_ = json.Unmarshal(w.Body.Bytes(), &list)
if len(list.Sources) != 1 {
t.Fatalf("应导入 1 个书源: %s", w.Body.String())
}
sourceID := list.Sources[0].ID
// 执行切换线路:这个请求会阻塞等待用户操作,放到后台跑
actionDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login",
strings.NewReader(`{"action":"switchLine()","fields":{}}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
actionDone <- rec
}()
// 轮询待办页面
var page struct {
ID string `json:"id"`
Title string `json:"title"`
Mode string `json:"mode"`
PageURL string `json:"page_url"`
SourceID string `json:"source_id"`
}
deadline := time.Now().Add(5 * time.Second)
for {
if time.Now().After(deadline) {
t.Fatal("等待超时:未出现待办页面")
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet,
"/api/reader/browser/pending?source_id="+sourceID, nil))
if w.Code != http.StatusOK {
t.Fatalf("轮询 status=%d body=%s", w.Code, w.Body.String())
}
var pending struct {
Pages []json.RawMessage `json:"pages"`
}
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
t.Fatalf("待办返回不是 JSON: %s", w.Body.String())
}
if len(pending.Pages) > 0 {
if err := json.Unmarshal(pending.Pages[0], &page); err != nil {
t.Fatal(err)
}
break
}
time.Sleep(20 * time.Millisecond)
}
if page.Mode != "wait" || page.Title != "线路设置" {
t.Fatalf("待办描述异常: %+v", page)
}
if page.SourceID != sourceID {
t.Fatalf("待办应带书源 ID: %+v", page)
}
// 承载页面:签名正确应返回 HTML,签名错误应 403
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, page.PageURL, nil))
if w.Code != http.StatusOK {
t.Fatalf("承载页面 status=%d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `id="serverValue"`) {
t.Fatalf("承载页面内容异常: %s", w.Body.String())
}
// 服务端注入的 DOM 回传脚本必须在(父窗口靠它取回用户操作后的页面)
if !strings.Contains(w.Body.String(), "data-mebox-browser-bridge") {
t.Fatalf("承载页面缺少 DOM 回传脚本: %s", w.Body.String())
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/browser/page?id="+page.ID+"&s=forged", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("伪造签名应 403,实际 %d", w.Code)
}
// 回传用户操作后的 DOM(模拟点了 √)
result, _ := json.Marshal(map[string]any{
"id": page.ID,
"body": `<html><body><span id="serverValue">线路乙</span></body></html>`,
})
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/reader/browser/result", strings.NewReader(string(result)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("回传结果 status=%d body=%s", w.Code, w.Body.String())
}
// 阻塞的登录动作应被放行
select {
case rec := <-actionDone:
if rec.Code != http.StatusOK {
t.Fatalf("登录动作 status=%d body=%s", rec.Code, rec.Body.String())
}
var res struct {
OK bool `json:"ok"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &res)
if !res.OK {
t.Fatalf("动作未成功: %s", rec.Body.String())
}
case <-time.After(5 * time.Second):
t.Fatal("回传后阻塞未解除")
}
// 线路应已写进源变量
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
var info struct {
Variable string `json:"variable"`
}
_ = json.Unmarshal(w.Body.Bytes(), &info)
if !strings.Contains(info.Variable, "线路乙") {
t.Fatalf("线路未写入源变量: %q", info.Variable)
}
// 完成后待办清空
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet,
"/api/reader/browser/pending?source_id="+sourceID, nil))
var after struct {
Pages []json.RawMessage `json:"pages"`
}
_ = json.Unmarshal(w.Body.Bytes(), &after)
if len(after.Pages) != 0 {
t.Fatalf("完成后待办未清理: %s", w.Body.String())
}
}
func TestReaderBrowserResultRejectsUnknownPage(t *testing.T) {
container := newReaderHandlerContainer(t)
router := registerBrowserRoutesForTest(container)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/browser/result",
strings.NewReader(`{"id":"not-a-real-page","body":"x"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("未知待办应 400,实际 %d body=%s", w.Code, w.Body.String())
}
}
func TestReaderBrowserResultRequiresID(t *testing.T) {
container := newReaderHandlerContainer(t)
router := registerBrowserRoutesForTest(container)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/browser/result", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("缺少 id 应 400,实际 %d", w.Code)
}
}
// TestReaderBrowserPageBadQuery 承载地址参数缺失/非法时不应 500。
func TestReaderBrowserPageBadQuery(t *testing.T) {
container := newReaderHandlerContainer(t)
router := registerBrowserRoutesForTest(container)
for _, target := range []string{"/api/reader/browser/page", "/api/reader/browser/page?id=x",
"/api/reader/browser/asset", "/api/reader/browser/asset?id=x&u=!!!&s=y"} {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("%s 应 403,实际 %d", target, w.Code)
}
}
// 合法 base64 但不匹配的签名
w := httptest.NewRecorder()
q := url.Values{"id": {"x"}, "u": {"aHR0cHM6Ly9leGFtcGxlLmNvbS8"}, "s": {"deadbeef"}}
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/browser/asset?"+q.Encode(), nil))
if w.Code != http.StatusForbidden {
t.Fatalf("签名不匹配应 403,实际 %d", w.Code)
}
}
+40
View File
@@ -0,0 +1,40 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 段评打开接口的 HTTP 层契约:路由存在、参数校验生效、内网地址被拒。
//
// 正常路径(真的去抓评论页)依赖外部站点,放在服务层解析单测里覆盖;
// 这里只钉住契约与安全边界,避免把测试绑到网络。
func TestReaderOpenCommentRejectsBadURL(t *testing.T) {
container := newReaderHandlerContainer(t)
router := registerReaderRoutesForTest(container)
post := func(body string) int {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/comments/open", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
return w.Code
}
cases := []struct {
name string
body string
}{
{"缺少 url", `{"book_id":"x"}`},
{"非 http 协议", `{"book_id":"x","url":"javascript:alert(1)"}`},
{"回环地址", `{"book_id":"x","url":"http://127.0.0.1/c"}`},
{"内网地址", `{"book_id":"x","url":"http://192.168.1.1/c"}`},
}
for _, c := range cases {
if code := post(c.body); code != http.StatusBadRequest {
t.Fatalf("%s:应 400,得到 %d", c.name, code)
}
}
}
+240
View File
@@ -0,0 +1,240 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 书架分组接口(/reader/book-groups)的 HTTP 契约测试。
//
// 分组仿影视模块的媒体库标签:整份替换、一本书只归一个组、按用户隔离。
// 另外两条是书籍特有的:只保留书架上的书(书移出后不留死 ID)、空分组保留。
func addBookForGroups(t *testing.T, svc *service.Container, userID, name, bookURL string) *model.ReaderBook {
t.Helper()
book, err := svc.Reader.AddBook(t.Context(), userID, reader.SearchOrigin{
Origin: "https://example.com",
BookURL: bookURL,
}, name, "作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
return book
}
func setBookGroups(t *testing.T, router *gin.Engine, user string, groups []model.BookGroupSet) ([]model.BookGroupSet, int) {
t.Helper()
body, _ := json.Marshal(map[string]any{"groups": groups})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/reader/book-groups", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Test-User", user)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
return nil, w.Code
}
var res struct {
Groups []model.BookGroupSet `json:"groups"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
}
return res.Groups, w.Code
}
func getBookGroups(t *testing.T, router *gin.Engine, user string) ([]model.BookGroupSet, int) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil)
req.Header.Set("X-Test-User", user)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
return nil, w.Code
}
var res struct {
Groups []model.BookGroupSet `json:"groups"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
}
return res.Groups, w.Code
}
func namesOf(groups []model.BookGroupSet) string {
out := make([]string, 0, len(groups))
for _, g := range groups {
out = append(out, g.Name)
}
return strings.Join(out, ",")
}
func idsOf(groups []model.BookGroupSet, name string) string {
for _, g := range groups {
if g.Name == name {
return strings.Join(g.BookIDs, ",")
}
}
return "<missing>"
}
// TestBookGroupsEmptyStateIsArray 没有分组时应返回空数组,前端可直接遍历。
func TestBookGroupsEmptyStateIsArray(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
// 必须是 [] 而不是 null
if !strings.Contains(w.Body.String(), `"groups":[]`) {
t.Fatalf("空态应返回 [],实际 %s", w.Body.String())
}
}
func TestBookGroupsNormalizeFilterAndDedupe(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2")
b3 := addBookForGroups(t, svc, "u1", "书三", "https://example.com/3")
foreign := addBookForGroups(t, svc, "u2", "别人的书", "https://example.com/9")
got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
{Name: "科幻", BookIDs: []string{b1.ID, b2.ID}},
{Name: "在读", BookIDs: []string{b1.ID}}, // b1 已被「科幻」认领 → 应变空
{Name: "玄幻", BookIDs: []string{b3.ID}},
{Name: "空组", BookIDs: []string{}},
{Name: " ", BookIDs: []string{b2.ID}}, // 空名 → 丢弃整个分组
{Name: "外来", BookIDs: []string{foreign.ID}}, // 不属于 u1 的书架 → 过滤掉
})
if code != http.StatusOK {
t.Fatalf("保存失败 status=%d", code)
}
if names := namesOf(got); names != "科幻,在读,玄幻,空组,外来" {
t.Fatalf("分组顺序/数量 = %q,期望 科幻,在读,玄幻,空组,外来", names)
}
if ids := idsOf(got, "科幻"); ids != b1.ID+","+b2.ID {
t.Fatalf("科幻 = %q,期望 %s,%s", ids, b1.ID, b2.ID)
}
if ids := idsOf(got, "在读"); ids != "" {
t.Fatalf("在读 应为空(b1 归了科幻),实际 %q", ids)
}
if ids := idsOf(got, "玄幻"); ids != b3.ID {
t.Fatalf("玄幻 = %q,期望 %s", ids, b3.ID)
}
if ids := idsOf(got, "外来"); ids != "" {
t.Fatalf("外来 应被过滤为空,实际 %q", ids)
}
// 读回来应当一致
loaded, code := getBookGroups(t, router, "u1")
if code != http.StatusOK {
t.Fatalf("读回 status=%d", code)
}
if names := namesOf(loaded); names != namesOf(got) {
t.Fatalf("读回分组 = %q,期望 %q", names, namesOf(got))
}
if ids := idsOf(loaded, "玄幻"); ids != b3.ID {
t.Fatalf("读回 玄幻 = %q,期望 %s", ids, b3.ID)
}
}
// TestBookGroupsDropsBooksRemovedFromShelf 书被移出书架后,分组里不应留下死 ID。
func TestBookGroupsDropsBooksRemovedFromShelf(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2")
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
{Name: "科幻", BookIDs: []string{b1.ID, b2.ID}},
}); code != http.StatusOK {
t.Fatalf("保存失败 status=%d", code)
}
if err := svc.Reader.RemoveBook(t.Context(), "u1", b2.ID); err != nil {
t.Fatalf("移出书架失败: %v", err)
}
loaded, _ := getBookGroups(t, router, "u1")
if ids := idsOf(loaded, "科幻"); ids != b1.ID {
t.Fatalf("科幻 = %q,期望只剩 %s", ids, b1.ID)
}
}
func TestBookGroupsArePerUser(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
u1Book := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
u2Book := addBookForGroups(t, svc, "u2", "书二", "https://example.com/2")
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
{Name: "u1 的分组", BookIDs: []string{u1Book.ID}},
}); code != http.StatusOK {
t.Fatalf("u1 保存失败 status=%d", code)
}
// u2 看不到 u1 的分组
if groups, code := getBookGroups(t, router, "u2"); code != http.StatusOK || len(groups) != 0 {
t.Fatalf("u2 分组 = %+v (status=%d),期望空", groups, code)
}
// u2 把 u1 的书塞进自己的分组也不会生效(不属于 u2 的书架)
if groups, code := setBookGroups(t, router, "u2", []model.BookGroupSet{
{Name: "抢过来", BookIDs: []string{u1Book.ID, u2Book.ID}},
}); code != http.StatusOK || idsOf(groups, "抢过来") != u2Book.ID {
t.Fatalf("u2 分组 = %+v (status=%d),期望只含自己的书", groups, code)
}
// u1 的分组不受影响
back, _ := getBookGroups(t, router, "u1")
if names := namesOf(back); names != "u1 的分组" || idsOf(back, "u1 的分组") != u1Book.ID {
t.Fatalf("u1 分组被 u2 影响: %+v", back)
}
}
// TestBookGroupsSaveTwiceReplacesWholeSet 整份替换语义:第二次保存覆盖第一次。
func TestBookGroupsSaveTwiceReplacesWholeSet(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
{Name: "旧组", BookIDs: []string{b1.ID}},
}); code != http.StatusOK {
t.Fatalf("首次保存 status=%d", code)
}
got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
{Name: "新组", BookIDs: []string{}},
})
if code != http.StatusOK {
t.Fatalf("二次保存 status=%d", code)
}
if names := namesOf(got); names != "新组" {
t.Fatalf("二次保存后 = %q,期望只有 新组", names)
}
// 清空分组
if groups, code := setBookGroups(t, router, "u1", nil); code != http.StatusOK || len(groups) != 0 {
t.Fatalf("清空后 = %+v (status=%d),期望空", groups, code)
}
if groups, _ := getBookGroups(t, router, "u1"); len(groups) != 0 {
t.Fatalf("清空后读回 = %+v,期望空", groups)
}
}
+174
View File
@@ -0,0 +1,174 @@
package handler
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// TestReaderLocalAudioStream 本地有声书音频流端点:
// 签名鉴权 + Range 透传(播放器拖进度靠它)。
func TestReaderLocalAudioStream(t *testing.T) {
container := newReaderHandlerContainer(t)
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/reader/local/audio", readerLocalAudioHandler(container))
router.HEAD("/api/reader/local/audio", readerLocalAudioHandler(container))
payload := []byte("0123456789abcdefghij")
path := filepath.Join(t.TempDir(), "track.mp3")
if err := os.WriteFile(path, payload, 0o640); err != nil {
t.Fatal(err)
}
signed := container.Reader.LocalAudioURL("book-1", path)
// 全量拉取
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
if w.Code != http.StatusOK {
t.Fatalf("全量拉取 status=%d body=%s", w.Code, w.Body.String())
}
if w.Body.String() != string(payload) {
t.Fatalf("全量拉取 body=%q", w.Body.String())
}
// Range 请求要能拖进度
w = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signed, nil)
req.Header.Set("Range", "bytes=2-5")
router.ServeHTTP(w, req)
if w.Code != http.StatusPartialContent {
t.Fatalf("Range 请求 status=%d", w.Code)
}
if w.Body.String() != "2345" {
t.Fatalf("Range 请求 body=%q", w.Body.String())
}
// 篡改签名应被拒绝
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed+"x", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("坏签名 status=%d,期望 403", w.Code)
}
// 文件不存在 → 404(而不是 500)
missing := container.Reader.LocalAudioURL("book-1", filepath.Join(t.TempDir(), "nope.mp3"))
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, missing, nil))
if w.Code != http.StatusNotFound {
t.Fatalf("缺失文件 status=%d,期望 404", w.Code)
}
}
// TestReaderAudioTranscodeRoute 转码端点:签名鉴权,命中缓存后按 Range 下发。
// 用预置的缓存文件代替真实转码,避免测试依赖 ffmpeg。
func TestReaderAudioTranscodeRoute(t *testing.T) {
container := newReaderHandlerContainer(t)
container.Cfg.App.DataDir = t.TempDir() // 转码缓存落在临时目录,别写进仓库
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
router.HEAD("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
const bookID = "book-1"
source := `D:\media\斗破苍穹\001.wma`
payload := []byte("ID3FAKEMP3PAYLOAD0123456789")
cachePath, err := container.Reader.AudioTranscodeCachePath(bookID, source)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cachePath, payload, 0o640); err != nil {
t.Fatal(err)
}
signed := container.Reader.AudioTranscodeURL(bookID, source)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
if w.Code != http.StatusOK {
t.Fatalf("全量拉取 status=%d body=%s", w.Code, w.Body.String())
}
if w.Body.String() != string(payload) {
t.Fatalf("全量拉取 body=%q", w.Body.String())
}
// 拖进度靠 Range
w = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signed, nil)
req.Header.Set("Range", "bytes=3-7")
router.ServeHTTP(w, req)
if w.Code != http.StatusPartialContent {
t.Fatalf("Range 请求 status=%d", w.Code)
}
if w.Body.String() != "FAKEM" {
t.Fatalf("Range 请求 body=%q", w.Body.String())
}
// 篡改签名应被拒绝
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed+"x", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("坏签名 status=%d,期望 403", w.Code)
}
// 换一本书(签名不匹配)同样拒绝
forged := strings.Replace(signed, "b=book-1", "b=book-2", 1)
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, forged, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("换书后 status=%d,期望 403", w.Code)
}
}
// TestReaderAudioTranscodeRouteWithoutFFmpeg 没装 ffmpeg 且无缓存时返回明确错误,
// 而不是空响应(前端据此提示用户去装 ffmpeg)。
func TestReaderAudioTranscodeRouteWithoutFFmpeg(t *testing.T) {
container := newReaderHandlerContainer(t)
container.Cfg.App.DataDir = t.TempDir()
container.Cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
signed := container.Reader.AudioTranscodeURL("book-1", `D:\media\001.wma`)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
if w.Code != http.StatusBadGateway {
t.Fatalf("status=%d,期望 502", w.Code)
}
if !strings.Contains(w.Body.String(), "ffmpeg") {
t.Fatalf("错误正文应提到 ffmpeg: %q", w.Body.String())
}
}
// TestReaderLocalAudioRejectsOtherBook 换一本书的签名不通用:防止拿到别人的音频地址。
func TestReaderLocalAudioRejectsOtherBook(t *testing.T) {
container := newReaderHandlerContainer(t)
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/reader/local/audio", readerLocalAudioHandler(container))
path := filepath.Join(t.TempDir(), "track.mp3")
if err := os.WriteFile(path, []byte("x"), 0o640); err != nil {
t.Fatal(err)
}
signed := container.Reader.LocalAudioURL("book-1", path)
forged := strings.Replace(signed, "b=book-1", "b=book-2", 1)
if forged == signed {
t.Fatal("测试用例未改写 book 参数")
}
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, forged, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("换书后签名应失效,status=%d", w.Code)
}
}
+307
View File
@@ -0,0 +1,307 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 书源登录相关的 HTTP 层测试:路由注册与 JSON 契约。
func newReaderHandlerContainer(t *testing.T) *service.Container {
t.Helper()
// 唯一库名 + cache=shared:阅读链路会并发写会话状态,
// 而 :memory: 下每个连接各自一个库,并发写入对后续读取不可见。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
return &service.Container{
Repo: repos,
Cfg: cfg,
Log: zap.NewNop(),
Reader: reader.NewReaderService(cfg, zap.NewNop(), repos),
}
}
// loginHandlerSource 返回一个声明了 loginUrl/loginUi 的书源 JSON。
func loginHandlerSource(server string) string {
loginJS := `function login(flag) {
var payload = JSON.stringify({register_email: result['邮箱'], password: result['密码']});
var res = java.ajax(baseUrl + '/login_api,{"method":"POST","headers":{"Content-Type":"application/json"},"body":' + JSON.stringify(payload) + '}');
var data = JSON.parse(res);
if (data.code == 0) { cookie.setCookie(baseUrl, 'qttoken=' + data.key); java.toast('登录成功'); return true; }
java.toast(data.msg || '登录失败'); return false;
}`
ui, _ := json.Marshal([]map[string]any{
{"name": "邮箱", "type": "text"},
{"name": "密码", "type": "password"},
{"name": "登录", "type": "button", "action": "login(true)"},
})
src := map[string]any{
"bookSourceUrl": server,
"bookSourceName": "登录源",
"bookSourceType": 0,
"loginUrl": loginJS,
"loginUi": string(ui),
"searchUrl": server + "/search",
"ruleSearch": map[string]any{
"bookList": "class.item",
"name": "tag.h3@tag.a@text",
"bookUrl": "tag.h3@tag.a@href",
},
}
b, _ := json.Marshal(src)
return string(b)
}
// loginHandlerServer 模拟需要登录的站点。
func loginHandlerServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login_api":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["register_email"] != "u@e.com" || body["password"] != "pw" {
_, _ = w.Write([]byte(`{"code":1,"msg":"账号或密码错误"}`))
return
}
http.SetCookie(w, &http.Cookie{Name: "qttoken", Value: "HTOKEN_1234567890", Path: "/"})
_, _ = w.Write([]byte(`{"code":0,"key":"HTOKEN_1234567890"}`))
case "/search":
if !strings.Contains(r.Header.Get("Cookie"), "qttoken=HTOKEN_1234567890") {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`<html>未登录</html>`))
return
}
_, _ = w.Write([]byte(`<html><body><div class="item"><h3><a href="/book/1">登录后可见</a></h3></div></body></html>`))
default:
http.NotFound(w, r)
}
}))
}
// registerReaderRoutesForTest 挂载阅读路由(跳过鉴权中间件)。
func registerReaderRoutesForTest(cfg *service.Container) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
registerReaderRoutes(r.Group("/api"), cfg)
return r
}
// TestReaderLoginRoutesEndToEnd 走完整 HTTP 路由:
// 查看登录信息 → 执行登录 → 搜索携带 Cookie 成功。
func TestReaderLoginRoutesEndToEnd(t *testing.T) {
srv := loginHandlerServer(t)
defer srv.Close()
cfg := newReaderHandlerContainer(t)
router := registerReaderRoutesForTest(cfg)
// 导入书源
body, _ := json.Marshal(map[string]string{"text": loginHandlerSource(srv.URL)})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("导入书源 status=%d body=%s", w.Code, w.Body.String())
}
// 列表应标注 has_login
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
if w.Code != http.StatusOK {
t.Fatalf("书源列表 status=%d", w.Code)
}
var listResp struct {
Sources []struct {
ID string `json:"id"`
HasLogin bool `json:"has_login"`
} `json:"sources"`
}
if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil || len(listResp.Sources) != 1 {
t.Fatalf("书源列表解析失败: %s", w.Body.String())
}
if !listResp.Sources[0].HasLogin {
t.Fatal("has_login 应为 true")
}
sourceID := listResp.Sources[0].ID
// GET 登录信息:应返回表单字段与未登录状态
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
if w.Code != http.StatusOK {
t.Fatalf("登录信息 status=%d body=%s", w.Code, w.Body.String())
}
var info struct {
HasLoginJS bool `json:"has_login_js"`
LoggedIn bool `json:"logged_in"`
Fields []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"fields"`
}
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
t.Fatalf("登录信息解析失败: %s", w.Body.String())
}
if !info.HasLoginJS || info.LoggedIn {
t.Fatalf("初始状态异常: %+v", info)
}
var names []string
for _, f := range info.Fields {
names = append(names, f.Name)
}
for _, want := range []string{"邮箱", "密码", "登录"} {
if !slices.Contains(names, want) {
t.Fatalf("缺少表单字段 %q: %v", want, names)
}
}
// 密码错误 → ok=false,提示透传
loginBody, _ := json.Marshal(map[string]any{
"fields": map[string]string{"邮箱": "u@e.com", "密码": "bad"},
})
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login", strings.NewReader(string(loginBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("登录 status=%d body=%s", w.Code, w.Body.String())
}
var failRes struct {
OK bool `json:"ok"`
LoggedIn bool `json:"logged_in"`
Toasts []string `json:"toasts"`
}
_ = json.Unmarshal(w.Body.Bytes(), &failRes)
if failRes.LoggedIn {
t.Fatalf("密码错误不应登录成功: %s", w.Body.String())
}
if !strings.Contains(strings.Join(failRes.Toasts, " "), "密码错误") {
t.Fatalf("失败提示未透传: %s", w.Body.String())
}
// 正确密码 → ok=true 且已登录
loginBody, _ = json.Marshal(map[string]any{
"fields": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
})
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login", strings.NewReader(string(loginBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("登录 status=%d body=%s", w.Code, w.Body.String())
}
var okRes struct {
OK bool `json:"ok"`
LoggedIn bool `json:"logged_in"`
Cookies map[string]string `json:"cookies"`
}
if err := json.Unmarshal(w.Body.Bytes(), &okRes); err != nil {
t.Fatal(err)
}
if !okRes.OK || !okRes.LoggedIn || len(okRes.Cookies) == 0 {
t.Fatalf("登录应成功并带 Cookie: %s", w.Body.String())
}
// 搜索应携带 Cookie 并拿到结果
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/reader/search", strings.NewReader(`{"key":"任意"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
var searchRes struct {
Books []struct{ Name string } `json:"books"`
Skipped []struct {
Reason string `json:"reason"`
} `json:"skipped"`
}
_ = json.Unmarshal(w.Body.Bytes(), &searchRes)
if len(searchRes.Books) != 1 || searchRes.Books[0].Name != "登录后可见" {
t.Fatalf("登录后搜索应成功: %s", w.Body.String())
}
// 登出 → 登录态清空
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/reader/sources/"+sourceID+"/login", nil))
if w.Code != http.StatusOK {
t.Fatalf("登出 status=%d", w.Code)
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
var afterLogout struct {
LoggedIn bool `json:"logged_in"`
Cookies map[string]string `json:"cookies"`
}
_ = json.Unmarshal(w.Body.Bytes(), &afterLogout)
if afterLogout.LoggedIn || len(afterLogout.Cookies) != 0 {
t.Fatalf("登出后应无登录态: %s", w.Body.String())
}
}
// TestReaderSetSourceVariableRoute 源变量接口应保存合法 JSON 并拒绝非法 JSON。
func TestReaderSetSourceVariableRoute(t *testing.T) {
cfg := newReaderHandlerContainer(t)
router := registerReaderRoutesForTest(cfg)
body, _ := json.Marshal(map[string]string{"text": `{"bookSourceUrl":"https://v.example.com","bookSourceName":"变量源"}`})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
var list struct {
Sources []struct {
ID string `json:"id"`
} `json:"sources"`
}
_ = json.Unmarshal(w.Body.Bytes(), &list)
if len(list.Sources) != 1 {
t.Fatalf("应导入 1 个书源: %s", w.Body.String())
}
id := list.Sources[0].ID
// 合法 JSON
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPut, "/api/reader/sources/"+id+"/variable",
strings.NewReader(`{"variable":"{\"线路\":\"https://v2.example.com\"}"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("保存变量 status=%d body=%s", w.Code, w.Body.String())
}
// 非法 JSON 应 400
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPut, "/api/reader/sources/"+id+"/variable",
strings.NewReader(`{"variable":"not-json"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("非法变量应 400,实际 %d body=%s", w.Code, w.Body.String())
}
}
+248
View File
@@ -0,0 +1,248 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 阅读器偏好接口(/reader/profile)的 HTTP 契约测试。
//
// 这些设置原先只存在浏览器 localStorage(设备级),换设备就丢。现在按用户落库,
// 所以要保证:新用户没有记录、写读一致、越界值收敛、以及用户之间互不影响。
// profileRouterForTest 用请求头 X-Test-User 指定调用者(默认 u1),
// 便于在一个测试里切换用户验证隔离。
func profileRouterForTest(t *testing.T, cfg *service.Container) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
user := c.GetHeader("X-Test-User")
if user == "" {
user = "u1"
}
c.Set(middleware.CtxUserID, user)
c.Next()
})
registerReaderRoutes(r.Group("/api"), cfg)
return r
}
func getProfile(t *testing.T, router *gin.Engine, user string) (*reader.ReaderSettings, int) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/reader/profile", nil)
req.Header.Set("X-Test-User", user)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
return nil, w.Code
}
var res struct {
Profile *reader.ReaderSettings `json:"profile"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
}
return res.Profile, w.Code
}
func putProfile(t *testing.T, router *gin.Engine, user, body string) (*reader.ReaderSettings, int) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/reader/profile", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Test-User", user)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
return nil, w.Code
}
var res struct {
Profile *reader.ReaderSettings `json:"profile"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
}
return res.Profile, w.Code
}
func TestReaderProfileNullBeforeFirstSave(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
profile, code := getProfile(t, router, "u1")
if code != http.StatusOK {
t.Fatalf("status=%d", code)
}
// 没有记录时返回 null,由前端用它本地的值播种
if profile != nil {
t.Fatalf("新用户应返回 null,实际 %+v", profile)
}
}
func TestReaderProfileRoundTrip(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
body := `{
"theme_id":"preset2","night":true,"page_mode":"scroll",
"font_size":24,"line_height":2.1,"paragraph_spacing":12,
"audio_speed":1.5,"audio_timer_minutes":30,
"shelf_layout":"compact","shelf_grid_columns":4,"shelf_sort":"update",
"shelf_show_unread":false,"shelf_show_update_time":true
}`
saved, code := putProfile(t, router, "u1", body)
if code != http.StatusOK {
t.Fatalf("保存失败 status=%d", code)
}
if saved == nil {
t.Fatal("保存应返回偏好")
}
got, code := getProfile(t, router, "u1")
if code != http.StatusOK || got == nil {
t.Fatalf("读回失败 status=%d profile=%+v", code, got)
}
if *got != *saved {
t.Fatalf("读回与写入不一致:\n写入 %+v\n读回 %+v", *saved, *got)
}
if got.ThemeID != "preset2" || !got.Night || got.PageMode != "scroll" || got.FontSize != 24 ||
got.LineHeight != 2.1 || got.ParagraphSpacing != 12 || got.AudioSpeed != 1.5 ||
got.AudioTimerMinutes != 30 || got.ShelfLayout != "compact" || got.ShelfGridColumns != 4 ||
got.ShelfSort != "update" || got.ShelfShowUnread || !got.ShelfShowUpdateTime {
t.Fatalf("字段未按原值往返: %+v", *got)
}
}
func TestReaderProfileClampsAndFallsBack(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
// 全部越界 / 枚举非法,服务端应收敛而不是原样入库
body := `{
"theme_id":" ","night":false,"page_mode":"nope",
"font_size":999,"line_height":0.1,"paragraph_spacing":-5,
"audio_speed":99,"audio_timer_minutes":9999,
"shelf_layout":"diagonal","shelf_grid_columns":42,"shelf_sort":"random",
"shelf_show_unread":true,"shelf_show_update_time":false
}`
got, code := putProfile(t, router, "u1", body)
if code != http.StatusOK {
t.Fatalf("保存失败 status=%d", code)
}
if got.ThemeID != "preset1" {
t.Fatalf("空主题应回落到 preset1,实际 %q", got.ThemeID)
}
if got.PageMode != "page" {
t.Fatalf("非法 page_mode 应回落到 page,实际 %q", got.PageMode)
}
if got.FontSize != 32 {
t.Fatalf("font_size 应夹到上限 32,实际 %d", got.FontSize)
}
if got.LineHeight != 1.4 {
t.Fatalf("line_height 应夹到下限 1.4,实际 %v", got.LineHeight)
}
if got.ParagraphSpacing != 0 {
t.Fatalf("paragraph_spacing 应夹到下限 0,实际 %d", got.ParagraphSpacing)
}
if got.AudioSpeed != 3 {
t.Fatalf("audio_speed 应夹到上限 3,实际 %v", got.AudioSpeed)
}
if got.AudioTimerMinutes != 180 {
t.Fatalf("audio_timer_minutes 应夹到上限 180,实际 %d", got.AudioTimerMinutes)
}
if got.ShelfLayout != "grid" {
t.Fatalf("非法 shelf_layout 应回落到 grid,实际 %q", got.ShelfLayout)
}
if got.ShelfGridColumns != 6 {
t.Fatalf("shelf_grid_columns 应夹到上限 6,实际 %d", got.ShelfGridColumns)
}
if got.ShelfSort != "recent" {
t.Fatalf("非法 shelf_sort 应回落到 recent,实际 %q", got.ShelfSort)
}
// 布尔值不参与收敛,必须原样保留
if !got.ShelfShowUnread || got.ShelfShowUpdateTime {
t.Fatalf("布尔项被改动: unread=%v update=%v", got.ShelfShowUnread, got.ShelfShowUpdateTime)
}
}
func TestReaderProfileIsPerUser(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
if _, code := putProfile(t, router, "u1", `{
"theme_id":"preset3","night":true,"page_mode":"page","font_size":18,
"line_height":1.5,"paragraph_spacing":4,"audio_speed":1,
"audio_timer_minutes":0,"shelf_layout":"list","shelf_grid_columns":0,
"shelf_sort":"name","shelf_show_unread":true,"shelf_show_update_time":true
}`); code != http.StatusOK {
t.Fatalf("u1 保存失败 status=%d", code)
}
// 另一个用户不应看到 u1 的偏好,也不应在 u1 之外建出记录
other, code := getProfile(t, router, "u2")
if code != http.StatusOK {
t.Fatalf("u2 读取 status=%d", code)
}
if other != nil {
t.Fatalf("u2 应返回 null,实际 %+v", other)
}
if _, code := putProfile(t, router, "u2", `{
"theme_id":"preset4","night":false,"page_mode":"scroll","font_size":20,
"line_height":1.8,"paragraph_spacing":8,"audio_speed":2,
"audio_timer_minutes":60,"shelf_layout":"grid","shelf_grid_columns":3,
"shelf_sort":"author","shelf_show_unread":false,"shelf_show_update_time":false
}`); code != http.StatusOK {
t.Fatalf("u2 保存失败 status=%d", code)
}
// u1 的值不能被 u2 覆盖
back, code := getProfile(t, router, "u1")
if code != http.StatusOK || back == nil {
t.Fatalf("u1 读回失败 status=%d", code)
}
if back.ThemeID != "preset3" || back.ShelfLayout != "list" || back.ShelfSort != "name" || !back.Night {
t.Fatalf("u1 的偏好被 u2 覆盖: %+v", *back)
}
}
func TestReaderProfileSaveTwiceKeepsOneRow(t *testing.T) {
svc := newReaderHandlerContainer(t)
router := profileRouterForTest(t, svc)
first := `{"theme_id":"preset1","page_mode":"page","font_size":20,"line_height":1.8,
"paragraph_spacing":8,"audio_speed":1,"shelf_layout":"grid","shelf_sort":"recent"}`
if _, code := putProfile(t, router, "u1", first); code != http.StatusOK {
t.Fatalf("首次保存 status=%d", code)
}
second := `{"theme_id":"preset5","page_mode":"scroll","font_size":22,"line_height":2.0,
"paragraph_spacing":10,"audio_speed":1.25,"shelf_layout":"compact","shelf_sort":"mixed"}`
if _, code := putProfile(t, router, "u1", second); code != http.StatusOK {
t.Fatalf("二次保存 status=%d", code)
}
got, _ := getProfile(t, router, "u1")
if got == nil || got.ThemeID != "preset5" || got.ShelfSort != "mixed" {
t.Fatalf("二次保存未覆盖旧值: %+v", got)
}
// 一个用户只应有一条记录(唯一索引 + upsert)
var count int64
if err := svc.Repo.DB.Model(&model.ReaderProfile{}).
Where("user_id = ?", "u1").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("u1 的记录数 = %d,期望 1", count)
}
}
+170
View File
@@ -0,0 +1,170 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 阅读进度接口的 HTTP 契约测试。
//
// 听书(音频)的进度是 audio.currentTime,天然带小数;漫画是图片序号、文本是页码,
// 都是整数。同一个接口必须同时接住这两种形态,否则音频进度会被 400 丢掉
// (前端是 fire-and-forget,失败不报错),表现为「听书退出后从头开始」。
func progressRouterForTest(cfg *service.Container, userID string) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, userID)
c.Next()
})
registerReaderRoutes(r.Group("/api"), cfg)
return r
}
func putProgress(t *testing.T, router *gin.Engine, bookID, body string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/reader/books/"+bookID+"/progress", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
return w
}
func TestSaveProgressAcceptsFractionalPos(t *testing.T) {
svc := newReaderHandlerContainer(t)
ctx := t.Context()
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
Origin: "https://example.com",
BookURL: "https://example.com/book/1",
}, "有声书", "作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
router := progressRouterForTest(svc, "u1")
// 37.5 秒:音频进度上报的真实形态
w := putProgress(t, router, book.ID, `{"chapter_index":2,"pos":37.5,"chapter_title":"第 3 章"}`)
if w.Code != http.StatusOK {
t.Fatalf("音频进度应被接受,实际 status=%d body=%s", w.Code, w.Body.String())
}
books, err := svc.Reader.ListBooks(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if len(books) != 1 {
t.Fatalf("书架应有 1 本,实际 %d", len(books))
}
// 小数秒截断成整数秒落库(dur_chapter_pos 是 int)
if books[0].DurChapterPos != 37 {
t.Fatalf("进度 = %d,期望 37(37.5 截断)", books[0].DurChapterPos)
}
if books[0].DurChapterIndex != 2 || books[0].DurChapterTitle != "第 3 章" {
t.Fatalf("章节/标题未保存: index=%d title=%q", books[0].DurChapterIndex, books[0].DurChapterTitle)
}
}
func TestSaveProgressAcceptsIntegerPos(t *testing.T) {
svc := newReaderHandlerContainer(t)
ctx := t.Context()
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
Origin: "https://example.com",
BookURL: "https://example.com/book/2",
}, "文本", "", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
router := progressRouterForTest(svc, "u1")
// 文本/漫画仍是整数页码,不能被破坏
w := putProgress(t, router, book.ID, `{"chapter_index":7,"pos":12,"chapter_title":"第 8 章"}`)
if w.Code != http.StatusOK {
t.Fatalf("页码进度应被接受,实际 status=%d body=%s", w.Code, w.Body.String())
}
books, err := svc.Reader.ListBooks(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if books[0].DurChapterPos != 12 || books[0].DurChapterIndex != 7 {
t.Fatalf("进度 = pos %d / index %d,期望 12 / 7", books[0].DurChapterPos, books[0].DurChapterIndex)
}
}
func TestSaveProgressRejectsNegativePos(t *testing.T) {
svc := newReaderHandlerContainer(t)
ctx := t.Context()
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
Origin: "https://example.com",
BookURL: "https://example.com/book/3",
}, "文本", "", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
router := progressRouterForTest(svc, "u1")
// 负数不是合法进度,落库前应被夹到 0,而不是写入负值
w := putProgress(t, router, book.ID, `{"chapter_index":0,"pos":-5,"chapter_title":"第 1 章"}`)
if w.Code != http.StatusOK {
t.Fatalf("负数进度不应 500,实际 status=%d body=%s", w.Code, w.Body.String())
}
books, err := svc.Reader.ListBooks(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if books[0].DurChapterPos != 0 {
t.Fatalf("进度 = %d,期望夹到 0", books[0].DurChapterPos)
}
}
// TestSaveProgressRoundTripsThroughJSON 防回归:进度写进去要能原样读回来。
func TestSaveProgressRoundTripsThroughJSON(t *testing.T) {
svc := newReaderHandlerContainer(t)
ctx := t.Context()
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
Origin: "https://example.com",
BookURL: "https://example.com/book/4",
}, "有声书", "", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
router := progressRouterForTest(svc, "u1")
if w := putProgress(t, router, book.ID, `{"chapter_index":0,"pos":123.75,"chapter_title":"第 1 章"}`); w.Code != http.StatusOK {
t.Fatalf("保存进度失败 status=%d body=%s", w.Code, w.Body.String())
}
// 走列表接口(前端书架/阅读器恢复进度读的就是它)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/reader/books", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("书架列表 status=%d body=%s", w.Code, w.Body.String())
}
var res struct {
Books []struct {
DurChapterPos int `json:"dur_chapter_pos"`
} `json:"books"`
}
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
t.Fatalf("解析书架响应失败: %v", err)
}
if len(res.Books) != 1 || res.Books[0].DurChapterPos != 123 {
t.Fatalf("读回的进度 = %+v,期望 123", res.Books)
}
}
+942
View File
@@ -0,0 +1,942 @@
// Package handler — 阅读(legado 书源兼容)子系统路由。
package handler
import (
"io"
"net/http"
"os"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
g := authed.Group("/reader")
// 书源管理
g.GET("/sources", readerListSourcesHandler(svc))
g.POST("/sources/import", readerImportSourcesHandler(svc))
g.PATCH("/sources/:id", readerUpdateSourceHandler(svc))
g.DELETE("/sources/:id", readerDeleteSourceHandler(svc))
g.POST("/sources/:id/debug", readerDebugSourceHandler(svc))
// 书源登录与源变量(登录类书源必需)
g.GET("/sources/:id/login", readerSourceLoginInfoHandler(svc))
g.POST("/sources/:id/login", readerSourceLoginActionHandler(svc))
g.DELETE("/sources/:id/login", readerSourceLogoutHandler(svc))
g.PUT("/sources/:id/variable", readerSetSourceVariableHandler(svc))
g.PUT("/sources/:id/login-info", readerSetSourceLoginInfoHandler(svc))
// 书源 JS 的宿主浏览器(java.startBrowser / startBrowserAwait):
// 前端轮询待办 → 在 iframe 里承载页面 → 用户点 √ 回传 DOM。
g.GET("/browser/pending", readerBrowserPendingHandler(svc))
g.POST("/browser/result", readerBrowserResultHandler(svc))
// 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie)
g.POST("/browser/xhr", readerBrowserXHRHandler(svc))
// 段评:用宿主浏览器打开评论地址(带书源 Cookie/登录态,对应书源的 showCmt)
g.POST("/comments/open", readerOpenCommentHandler(svc))
// 搜索(多源聚合)
g.POST("/search", readerSearchHandler(svc))
// 详情 / 目录 / 正文
g.GET("/book-info", readerBookInfoHandler(svc))
g.GET("/toc", readerTocHandler(svc))
g.GET("/content", readerContentHandler(svc))
// 书架
g.GET("/books", readerListBooksHandler(svc))
g.POST("/books", readerAddBookHandler(svc))
// 本地书籍(TXT / EPUB 上传导入)
g.POST("/local/books", readerImportLocalBookHandler(svc))
// 服务器已有文件/目录导入:原地引用不复制,仅管理员(会读取允许根目录内的文件)
g.POST("/local/books/from-path", middleware.AdminRequired(), readerImportLocalBookFromPathHandler(svc))
g.POST("/local/audiobooks", middleware.AdminRequired(), readerImportLocalAudioDirHandler(svc))
g.DELETE("/books/:id", readerRemoveBookHandler(svc))
g.PUT("/books/:id/progress", readerSaveProgressHandler(svc))
// 更新目录:重抓书架里全部网络书籍的目录,刷新章节缓存与「最近更新」时间
g.POST("/shelf/refresh-toc", readerRefreshBooksTocHandler(svc))
// 阅读器偏好(每个用户一条):主题 / 排版 / 听书 / 书架展示设置,跨设备同步
g.GET("/profile", readerGetProfileHandler(svc))
g.PUT("/profile", readerSaveProfileHandler(svc))
// 书架分组(每个用户一份):组名 → 书籍 ID,仿影视模块的媒体库标签
g.GET("/book-groups", readerGetBookGroupsHandler(svc))
g.PUT("/book-groups", readerSetBookGroupsHandler(svc))
// 换源:把书架里的书切到另一个书源(保留阅读进度,目录缓存按新源重建)
g.POST("/books/:id/origin", readerSwitchOriginHandler(svc))
g.PUT("/books/:id/audio-config", readerSaveAudioConfigHandler(svc))
g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
g.GET("/books/:id/content", readerBookContentHandler(svc))
// 替换净化规则
g.GET("/replace-rules", readerListReplaceRulesHandler(svc))
g.POST("/replace-rules", readerCreateReplaceRuleHandler(svc))
g.PATCH("/replace-rules/:id", readerUpdateReplaceRuleHandler(svc))
g.DELETE("/replace-rules/:id", readerDeleteReplaceRuleHandler(svc))
}
func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
sources, err := svc.Reader.ListSources(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"sources": sources})
}
}
func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Text string `json:"text" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
imported, err := svc.Reader.ImportSources(c.Request.Context(), body.Text)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"imported": imported})
}
}
func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Enabled *bool `json:"enabled"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil || body.Enabled == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
return
}
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), c.Param("id"), *body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Reader.DeleteSource(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Key string `json:"key" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
logs, err := svc.Reader.Debug(c.Request.Context(), c.Param("id"), body.Key)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs})
}
}
func readerSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
info, err := svc.Reader.GetSourceLogin(c.Request.Context(), userID, c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func readerSourceLoginActionHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
// Action 为 loginUi 里按钮的 action;留空表示执行 login()(确认登录)。
Action string `json:"action"`
Fields map[string]string `json:"fields"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
res, err := svc.Reader.RunLoginAction(c.Request.Context(), userID, c.Param("id"), body.Action, body.Fields)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
// readerBrowserPendingHandler 前端轮询:该用户在某书源下待用户完成的页面。
// 书源的 java.startBrowserAwait 会阻塞在服务端,前端据此把页面呈现出来。
func readerBrowserPendingHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
pages := svc.Reader.PendingBrowserPages(userID, c.Query("source_id"))
c.JSON(http.StatusOK, gin.H{"pages": pages})
}
}
// readerBrowserResultHandler 用户完成页面后回传 DOM(或取消),解除服务端阻塞。
func readerBrowserResultHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ID string `json:"id" binding:"required"`
Body string `json:"body"`
URL string `json:"url"`
Cancelled bool `json:"cancelled"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.ResolveBrowser(body.ID, userID, body.Body, body.URL, body.Cancelled); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// readerBrowserXHRHandler 转发承载页面内的接口请求。
//
// 页面在 iframe 里是不透明源,自己的 XHR 既带不上书源 Cookie 也会被 CORS 拦,
// 所以由父窗口(持 JWT)把请求转交到这里,服务端补上书源凭据再发。
func readerBrowserXHRHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ID string `json:"id" binding:"required"`
URL string `json:"url" binding:"required"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
res, err := svc.Reader.ProxyBrowserXHR(
c.Request.Context(), body.ID, body.Method, body.URL, body.Headers, body.Body)
if err != nil {
// 交给页面自己处理失败,别把 4xx 泄成框架错误
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
// readerOpenCommentHandler 打开一条段评:复用书源登录态在宿主浏览器里承载评论页。
// 书源的 showCmt 内部就是「java.ajax 取评论页 → java.showBrowser 展示」,
// 这里用承载登录页的同一套机制等价实现(见 reader.OpenContentComment)。
func readerOpenCommentHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
BookID string `json:"book_id" binding:"required"`
URL string `json:"url" binding:"required"`
Title string `json:"title"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
page, err := svc.Reader.OpenContentComment(c.Request.Context(), userID, body.BookID, body.URL, body.Title)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, page)
}
}
// readerBrowserPageHandler 承载待办页面本体。//
// 鉴权走 HMAC 签名而非 JWT:这个地址要填进 <iframe src>,而 iframe 的请求
// 带不上 Authorization 头。签名绑定待办 ID,链接随待办一起过期。
func readerBrowserPageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
snap, err := svc.Reader.VerifyBrowserPage(c.Query("id"), c.Query("s"))
if err != nil {
c.String(http.StatusForbidden, "%s", err.Error())
return
}
c.Header("Cache-Control", "no-store")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(snap.HTML))
}
}
// readerBrowserAssetHandler 页面资源/表单/站内链接的同源代理。
// 服务端补上书源 Cookie 与请求头,使「用户后台」这类页面在 iframe 里保持登录态。
func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
target, _, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
if err != nil {
c.String(http.StatusForbidden, "%s", err.Error())
return
}
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), c.Query("id"), target)
if err != nil {
c.String(http.StatusBadGateway, "资源加载失败: %s", err.Error())
return
}
if strings.Contains(strings.ToLower(contentType), "text/css") {
data = []byte(svc.Reader.RewriteBrowserCSS(string(data), target, c.Query("id")))
}
c.Header("Cache-Control", "no-store")
c.Data(status, contentType, data)
}
}
func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerSetSourceVariableHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Variable string `json:"variable"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SetSourceVariable(c.Request.Context(), c.Param("id"), body.Variable); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Fields map[string]string `json:"fields"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), c.Param("id"), body.Fields); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Key string `json:"key" binding:"required"`
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
SourceIDs []string `json:"source_ids"`
// Page 页码(从 1 开始):书源 searchUrl 支持 {{page}} 时,前端滚到底逐页取。
Page int `json:"page"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs, body.Page)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"books": books, "skipped": skipped})
}
}
func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
info, err := svc.Reader.GetBookInfo(
c.Request.Context(),
c.Query("source_id"), c.Query("source_url"), c.Query("book_url"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func readerTocHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
chapters, err := svc.Reader.GetToc(
c.Request.Context(),
userID, c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("toc_url"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// nil 切片会被编码成 null,前端拿到 null 再取 .length 就是一句
// 「Cannot read properties of null」。空目录统一给 []。
if chapters == nil {
chapters = []reader.TocChapter{}
}
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
}
}
func readerContentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
content, err := svc.Reader.GetContent(
c.Request.Context(),
c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("chapter_url"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, content)
}
}
func readerListBooksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
books, err := svc.Reader.ListBooks(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"books": books})
}
}
func readerAddBookHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Origin reader.SearchOrigin `json:"origin" binding:"required"`
Name string `json:"name" binding:"required"`
Author string `json:"author"`
CoverURL string `json:"cover_url"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.AddBook(c.Request.Context(), userID, body.Origin, body.Name, body.Author, body.CoverURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 后台补目录缓存,让书架能显示未读章数;失败不影响加入书架本身。
svc.Reader.WarmUpBookChaptersAsync(c.Request.Context(), userID, book)
c.JSON(http.StatusOK, book)
}
}
// readerSwitchOriginHandler 换源:把书架里的书切到另一个书源。
// 阅读进度保留;旧源目录缓存清空后按新源后台重新预热。
func readerSwitchOriginHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Origin reader.SearchOrigin `json:"origin" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.SwitchOrigin(c.Request.Context(), userID, c.Param("id"), body.Origin)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
svc.Reader.WarmUpBookChaptersAsync(c.Request.Context(), userID, book)
c.JSON(http.StatusOK, book)
}
}
// readerImportLocalBookHandler 上传本地书籍(TXT / EPUB)并加入书架。
// 正文落盘到 data/reader/local,目录切分后与网络书籍共用阅读器链路。
func readerImportLocalBookHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// 先卡住请求体大小,避免超大文件把内存打满(多给 1MB 放 multipart 头)
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(reader.LocalBookMaxBytes)+(1<<20))
header, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少上传文件(表单字段 file),或文件超过大小上限"})
return
}
f, err := header.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取上传文件失败: " + err.Error()})
return
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, int64(reader.LocalBookMaxBytes)+1))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取上传文件失败: " + err.Error()})
return
}
if len(data) > reader.LocalBookMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件超过大小上限"})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.ImportLocalBook(c.Request.Context(), userID, header.Filename, data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, book)
}
}
// readerImportLocalBookFromPathHandler 从服务器已有文件导入书籍(TXT / EPUB),原地引用。
// 仅管理员:会读取服务器上允许根目录内的任意文件。
func readerImportLocalBookFromPathHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Path string `json:"path" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
abs, err := svc.FileManager.ResolvePath(body.Path)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.ImportLocalBookFromPath(c.Request.Context(), userID, abs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, book)
}
}
// readerImportLocalAudioDirHandler 把一个服务器目录导入为一本有声书,原地引用。
// 仅管理员;目录下的音频文件与 .strm 播放指针按相对路径排序成为章节。
func readerImportLocalAudioDirHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Path string `json:"path" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
abs, err := svc.FileManager.ResolvePath(body.Path)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.ImportLocalAudioDir(c.Request.Context(), userID, abs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, book)
}
}
// readerRefreshBooksTocHandler 更新目录:重抓书架里全部网络书籍的目录。
// 本地书籍与没有书源信息的书籍跳过;单本失败只计数,不影响其它书。
func readerRefreshBooksTocHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
res, err := svc.Reader.RefreshBooksToc(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func readerRemoveBookHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.RemoveBook(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// readerGetProfileHandler 读当前用户的阅读器偏好。
// 没保存过时返回 {"profile": null},前端据此用本地值播种。
func readerGetProfileHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
settings, err := svc.Reader.GetReaderSettings(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"profile": settings})
}
}
// readerSaveProfileHandler 覆盖保存当前用户的阅读器偏好(服务端做范围收敛)。
func readerSaveProfileHandler(svc *service.Container) gin.HandlerFunc {
var body reader.ReaderSettings
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
saved, err := svc.Reader.SaveReaderSettings(c.Request.Context(), userID, body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"profile": saved})
}
}
// readerGetBookGroupsHandler 读当前用户的书架分组。
// 没有分组时返回空数组(不是 null),前端可以直接遍历。
func readerGetBookGroupsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
groups, err := svc.Reader.GetBookGroups(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"groups": groups})
}
}
// readerSetBookGroupsHandler 覆盖保存当前用户的书架分组(整份替换)。
func readerSetBookGroupsHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Groups []model.BookGroupSet `json:"groups"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
groups, err := svc.Reader.SetBookGroups(c.Request.Context(), userID, body.Groups)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"groups": groups})
}
}
// maxReaderChapterPos 进度上限:秒(听书)/ 页码(文本)/ 图片序号(漫画)都远小于它,
// 只用来挡住异常大的浮点数转 int 时溢出。约 115 天,足够覆盖任何单章。
const maxReaderChapterPos = 10_000_000
func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ChapterIndex int `json:"chapter_index"`
// Pos 用 float64 接:听书的进度是 audio.currentTime(秒,天然带小数),
// 漫画是图片序号、文本是页码(都是整数)。用 int 接小数会让整个请求
// 400,而前端是 fire-and-forget,音频进度就被静默丢掉了。
Pos float64 `json:"pos"`
ChapterTitle string `json:"chapter_title"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// dur_chapter_pos 落库是 int,截断成秒;负数没有意义,夹到 0。
posFloat := body.Pos
if !(posFloat > 0) { // 同时挡住 0 与负数(JSON 不会给出 NaN)
posFloat = 0
}
if posFloat > maxReaderChapterPos {
posFloat = maxReaderChapterPos
}
pos := int(posFloat)
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SaveProgress(c.Request.Context(), userID, c.Param("id"), body.ChapterIndex, pos, body.ChapterTitle); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// readerSaveAudioConfigHandler 保存听书片头/片尾跳过秒数(0 为不跳过)。
func readerSaveAudioConfigHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
OpenCredits int `json:"open_credits"`
CloseCredits int `json:"close_credits"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SaveAudioConfig(
c.Request.Context(), userID, c.Param("id"), body.OpenCredits, body.CloseCredits); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerListChaptersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
chapters, err := svc.Reader.ListChapters(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
}
}
func readerReplaceChaptersHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Chapters []reader.ChapterInput `json:"chapters"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SaveChapters(c.Request.Context(), c.Param("id"), body.Chapters); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerListReplaceRulesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
rules, err := svc.Reader.ListReplaceRules(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rules": rules})
}
}
func readerCreateReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
var body reader.ReplaceRuleInput
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
rule, err := svc.Reader.CreateReplaceRule(c.Request.Context(), userID, body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rule)
}
}
func readerUpdateReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
var body reader.ReplaceRuleInput
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.UpdateReplaceRule(c.Request.Context(), userID, c.Param("id"), body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func readerDeleteReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.DeleteReplaceRule(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// readerMediaProxyHandler 音频流/漫画图片签名代理:
// 校验 HMAC 签名 → 携书源防盗链头拉取 → Range 透传(音频拖动)/ m3u8 重写。
func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
bookID := c.Query("b")
rawURL, err := svc.Reader.VerifyProxyURL(bookID, c.Query("u"), c.Query("s"))
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 http(s) 媒体地址"})
return
}
book, err := svc.Reader.GetBook(c.Request.Context(), bookID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"})
return
}
resp, err := svc.Reader.FetchMedia(c.Request.Context(), book, rawURL, c.GetHeader("Range"))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
return
}
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
isPlaylist := strings.Contains(ct, "mpegurl") || strings.Contains(ct, "m3u8") ||
strings.HasSuffix(strings.ToLower(rawURL), ".m3u8")
if isPlaylist {
// m3u8:改写分片/密钥地址为签名代理后返回,hls.js 无感续播
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(data))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
return
}
// 流式透传(含 206 Partial Content,支持音频拖动进度)
for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
c.Header(h, v)
}
}
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
}
}
// readerLocalAssetHandler 本地书籍内嵌资源(EPUB 图片等):
// 鉴权走 HMAC 签名(<img src> 带不上 JWT),与 /reader/media 同一套做法。
func readerLocalAssetHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
bookID := c.Query("b")
entry, err := svc.Reader.VerifyLocalAssetURL(bookID, c.Query("p"), c.Query("s"))
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
data, contentType, err := svc.Reader.ReadLocalAsset(c.Request.Context(), bookID, entry)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
// 同一本书的图片不会变,可长缓存
c.Header("Cache-Control", "private, max-age=604800")
c.Data(http.StatusOK, contentType, data)
}
}
// readerAudioTranscodeHandler 需要转码的有声书音轨:签名鉴权(<audio src> 带不上 JWT),
// 首次请求跑 ffmpeg 转成 mp3 落缓存,之后按 Range 下发,播放器可以拖动进度。
func readerAudioTranscodeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
bookID := c.Query("b")
source, err := svc.Reader.VerifyAudioTranscodeURL(bookID, c.Query("u"), c.Query("s"))
if err != nil {
c.String(http.StatusForbidden, "%s", err.Error())
return
}
path, err := svc.Reader.EnsureTranscodedAudio(c.Request.Context(), bookID, source)
if err != nil {
c.String(http.StatusBadGateway, "%s", err.Error())
return
}
f, err := os.Open(path) // #nosec G304 -- 路径由签名校验 + 缓存目录哈希生成
if err != nil {
c.String(http.StatusNotFound, "转码结果已丢失")
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
c.String(http.StatusNotFound, "转码结果不可用")
return
}
c.Header("Cache-Control", "private, max-age=604800")
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), f)
}
}
// readerLocalAudioHandler 本地有声书音频流:签名鉴权(<audio src> 带不上 JWT),
// 交给 http.ServeContent 处理 Range,播放器才能拖动进度。
func readerLocalAudioHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path, err := svc.Reader.VerifyLocalAudioURL(c.Query("b"), c.Query("p"), c.Query("s"))
if err != nil {
c.String(http.StatusForbidden, "%s", err.Error())
return
}
f, info, err := svc.Reader.OpenLocalAudio(path)
if err != nil {
c.String(http.StatusNotFound, "%s", err.Error())
return
}
defer f.Close()
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), f)
}
}
// readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。
func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
chapter, err := strconv.Atoi(c.DefaultQuery("chapter", "0"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter 参数需为整数"})
return
}
userID := c.GetString(middleware.CtxUserID)
content, err := svc.Reader.GetContentForBook(c.Request.Context(), userID, c.Param("id"), chapter)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, content)
}
}
+1
View File
@@ -30,4 +30,5 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
registerAuthedDLNAControlRoutes(authed, svc)
registerAuthedFavoriteAndMediaActionRoutes(authed, svc)
registerAuthedPlaybackExtraRoutes(authed, svc)
registerReaderRoutes(authed, svc)
}
+73 -2
View File
@@ -3,14 +3,66 @@
package helper
import (
"bytes"
"compress/flate"
"compress/gzip"
"compress/zlib"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// defaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
// DecompressBody 兜底解压响应体(gzip / deflate)。
//
// 正常情况下用不到:只要不显式设置 Accept-Encoding,net/http 会自己带上 gzip
// 并透明解压。但书源 JSON 的 header 字段、或某些 CDN 的固定策略,都可能让响应
// 带着 Content-Encoding 回来,压缩字节一旦进入规则层,书源的 JSON.parse 就会
// 报 `invalid character '\x1f'`。这里做一次兜底,保证调用方拿到明文。
//
// brotli(br)无法在纯 Go 标准库里解,因此上面不再 advertise br;真遇到 br
// 响应则原样返回,由调用方按失败处理。
func DecompressBody(resp *http.Response, data []byte) []byte {
if resp == nil || len(data) == 0 {
return data
}
switch strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding"))) {
case "gzip", "x-gzip":
r, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return data
}
defer r.Close()
if out, err := io.ReadAll(r); err == nil {
return out
}
case "deflate":
// deflate 有两种实际写法:zlib 包装与裸 DEFLATE,依次尝试。
if out, err := io.ReadAll(flate.NewReader(bytes.NewReader(data))); err == nil {
return out
}
if zr, err := zlib.NewReader(bytes.NewReader(data)); err == nil {
defer zr.Close()
if out, err := io.ReadAll(zr); err == nil {
return out
}
}
}
return data
}
// StripAcceptEncoding 移除显式设置的 Accept-Encoding,交回 net/http 管理。
//
// 只有「调用方没设置」时 net/http 才会自动解压,因此任何来源(预设头、书源
// header 字段)带来的 Accept-Encoding 都必须清掉。
func StripAcceptEncoding(h http.Header) {
h.Del("Accept-Encoding")
}
// NewSiteHTTPClient builds an http.Client honoring per-site policies:
// - timeout (seconds, defaults to 15)
// - proxy via HTTP(S)_PROXY environment variables when site.UseProxy is on
@@ -41,12 +93,31 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
// HTTPHeaderPresets returns a map of realistic browser HTTP headers.
// These mimic a real Chrome browser to avoid WAF/bot detection.
//
// 注意:这里刻意不设置 Accept-Encoding。
//
// net/http 只在「调用方没有显式设置 Accept-Encoding」时才会自己补上 gzip 并
// 透明解压;一旦我们显式写了这个头,它就原样把压缩字节交出来。之前这里写了
// "gzip, deflate, br",于是所有经历了压缩的响应(静态 JSON、CDN 上的页面等)
// 都会以原始压缩字节进入规则层,书源的 JSON.parse 直接报
// `SyntaxError: invalid character '\x1f'`,表现为「获取最新配置失败」。
//
// 交给 net/http 管理后:请求仍会带 `Accept-Encoding: gzip`(浏览器常见取值),
// 且响应被自动解压;另外也避免服务端挑选我们无法解码的 br。
// 注意:Accept 刻意用 `*/*` 而不是浏览器页面导航的
// `text/html,application/xhtml+xml,...`。
//
// 很多书源接口是 DRF(Django REST framework)一类会做内容协商的后端:当 Accept
// 首选 text/html 时,它按浏览器语义返回「可浏览的 HTML 页面」(HTTP 200),
// 而不是 JSON。引擎再把这份 HTML 交给 JSONPath 解析,结果永远是 0 条——表现为
// 「同一个源在手机 App 里能搜到,在 MeBox 里搜不到」(拷贝系列源就是这个坑)。
// 手机端阅读 App 走 OkHttp,不显式设置 Accept,等价于 `*/*`;这里对齐该行为。
// 需要页面型 Accept 的书源可以在自身 header 里显式声明覆盖。
func HTTPHeaderPresets() map[string]string {
return map[string]string{
"User-Agent": defaultUserAgent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
+85
View File
@@ -0,0 +1,85 @@
package helper
import (
"bytes"
"compress/gzip"
"net/http"
"testing"
)
// 本文件:压缩相关的回归测试。
//
// 背景:预设头里曾显式写 `Accept-Encoding: gzip, deflate, br`,导致 net/http
// 不再自动解压(它只在调用方没设置该头时才解压),压缩字节直接进入规则层。
// TestHeaderPresetsAcceptIsWildcard 预设 Accept 不得首选 text/html。
//
// 背景:DRF(Django REST framework)等会做内容协商的后端,在 Accept 首选
// text/html 时返回「可浏览 HTML 页面」而不是 JSON,书源 JSONPath 因此永远
// 解析出 0 条(拷贝轻小说源在 MeBox 搜不到、在阅读 App 能搜到的根因)。
func TestHeaderPresetsAcceptIsWildcard(t *testing.T) {
got := HTTPHeaderPresets()["Accept"]
if got != "*/*" {
t.Fatalf("Accept 预设应为 */*(对齐 legado/OkHttp),实际 %q", got)
}
}
// TestHeaderPresetsDoNotSetAcceptEncoding 预设头不得设置 Accept-Encoding。
// 一旦设置,net/http 的透明解压就失效,压缩响应会以原始字节交给上层。
func TestHeaderPresetsDoNotSetAcceptEncoding(t *testing.T) {
for k := range HTTPHeaderPresets() {
if http.CanonicalHeaderKey(k) == "Accept-Encoding" {
t.Fatal("HTTPHeaderPresets 不应设置 Accept-Encoding(会让 net/http 放弃自动解压)")
}
}
}
// TestStripAcceptEncoding 任何来源带来的 Accept-Encoding 都应被清掉。
func TestStripAcceptEncoding(t *testing.T) {
h := http.Header{}
h.Set("Accept-Encoding", "br, gzip")
StripAcceptEncoding(h)
if got := h.Get("Accept-Encoding"); got != "" {
t.Fatalf("Accept-Encoding 未清理: %q", got)
}
}
// TestDecompressBody gzip / deflate 响应体应被还原成明文。
func TestDecompressBody(t *testing.T) {
payload := `{"version":"20260926"}`
t.Run("gzip", func(t *testing.T) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
_, _ = gz.Write([]byte(payload))
_ = gz.Close()
resp := &http.Response{Header: http.Header{}}
resp.Header.Set("Content-Encoding", "gzip")
if got := string(DecompressBody(resp, buf.Bytes())); got != payload {
t.Fatalf("gzip 解压 = %q", got)
}
})
t.Run("未压缩原样返回", func(t *testing.T) {
resp := &http.Response{Header: http.Header{}}
if got := string(DecompressBody(resp, []byte(payload))); got != payload {
t.Fatalf("未压缩响应被改动: %q", got)
}
})
t.Run("坏数据不 panic", func(t *testing.T) {
resp := &http.Response{Header: http.Header{}}
resp.Header.Set("Content-Encoding", "gzip")
raw := []byte("not-gzip")
if got := string(DecompressBody(resp, raw)); got != "not-gzip" {
t.Fatalf("解压失败时应原样返回,实际 %q", got)
}
})
t.Run("nil resp", func(t *testing.T) {
if got := string(DecompressBody(nil, []byte(payload))); got != payload {
t.Fatalf("nil resp 应原样返回,实际 %q", got)
}
})
}
+87
View File
@@ -0,0 +1,87 @@
// Package helper — AES-256-GCM at-rest secret encryption.
//
// Sensitive values (third-party API keys, service passwords, book-source
// login credentials) are stored in the database. Encrypting them keyed off
// a per-install secret means a stolen DB file alone is not enough to
// recover the plaintext.
//
// Format on disk: "enc:v1:" + base64(nonce || ciphertext || tag).
// Legacy plaintext rows (no prefix) round-trip unchanged so an upgraded
// install needs no migration step.
package helper
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"strings"
)
// EncPrefix tags ciphertext rows so they can be told apart from plaintext.
const EncPrefix = "enc:v1:"
// SecretCipher encrypts/decrypts strings with AES-256-GCM derived from a
// stable per-install secret. A zero-value SecretCipher (empty key) is a
// pass-through, which keeps unit tests and keyless dev setups working.
type SecretCipher struct {
aead cipher.AEAD
}
// NewSecretCipher derives a 256-bit key from secret via SHA-256.
func NewSecretCipher(secret string) *SecretCipher {
if strings.TrimSpace(secret) == "" {
return &SecretCipher{}
}
sum := sha256.Sum256([]byte(secret))
block, err := aes.NewCipher(sum[:])
if err != nil {
return &SecretCipher{}
}
aead, err := cipher.NewGCM(block)
if err != nil {
return &SecretCipher{}
}
return &SecretCipher{aead: aead}
}
// Ready 表示是否真的能加解密(密钥有效)。
func (c *SecretCipher) Ready() bool { return c != nil && c.aead != nil }
// Encrypt returns the prefixed base64 ciphertext for plain.
// Empty inputs and already-encrypted values round-trip unchanged.
func (c *SecretCipher) Encrypt(plain string) string {
if plain == "" || !c.Ready() || strings.HasPrefix(plain, EncPrefix) {
return plain
}
nonce := make([]byte, c.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return plain
}
sealed := c.aead.Seal(nonce, nonce, []byte(plain), nil)
return EncPrefix + base64.StdEncoding.EncodeToString(sealed)
}
// Decrypt returns the plaintext for a stored value. Plaintext rows (no
// prefix) are returned unchanged, as are values that fail to authenticate.
func (c *SecretCipher) Decrypt(value string) string {
if value == "" || !c.Ready() || !strings.HasPrefix(value, EncPrefix) {
return value
}
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, EncPrefix))
if err != nil || len(data) < c.aead.NonceSize() {
return value
}
nonce, body := data[:c.aead.NonceSize()], data[c.aead.NonceSize():]
plain, err := c.aead.Open(nil, nonce, body, nil)
if err != nil {
return value
}
return string(plain)
}
// IsEncrypted reports whether value carries the encrypted prefix.
func (c *SecretCipher) IsEncrypted(value string) bool {
return strings.HasPrefix(value, EncPrefix)
}
+33 -32
View File
@@ -26,38 +26,39 @@ type LibraryRoot struct {
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct {
Base
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
SizeBytes int64 `json:"size_bytes"`
DurationSec int `json:"duration_sec"`
Width int `json:"width"`
Height int `json:"height"`
VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"`
AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"`
Container string `gorm:"size:128" json:"container,omitempty"`
PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"`
BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"`
Overview string `gorm:"type:text" json:"overview,omitempty"`
Rating float32 `json:"rating"`
Year int `json:"year"`
ReleaseDate string `gorm:"size:10;index:idx_media_library_release,priority:2" json:"release_date,omitempty"`
SeasonNum int `json:"season_num"`
EpisodeNum int `json:"episode_num"`
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
TMDbID int `json:"tmdb_id"`
BangumiID int `json:"bangumi_id"`
DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"`
TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"`
Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en"
Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US"
Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation"
NSFW bool `gorm:"default:false" json:"nsfw"`
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
SizeBytes int64 `json:"size_bytes"`
DurationSec int `json:"duration_sec"`
Width int `json:"width"`
Height int `json:"height"`
VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"`
AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"`
Container string `gorm:"size:128" json:"container,omitempty"`
PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"`
BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"`
Overview string `gorm:"type:text" json:"overview,omitempty"`
Rating float32 `json:"rating"`
Year int `json:"year"`
ReleaseDate string `gorm:"size:10;index:idx_media_library_release,priority:2" json:"release_date,omitempty"`
SeasonNum int `json:"season_num"`
EpisodeNum int `json:"episode_num"`
EpisodeFraction float64 `gorm:"default:0" json:"episode_fraction,omitempty"` // 集号小数部分(S01E11.5 → 0.5);Emby 的 IndexNumber 只能是整数,小数单独存放
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
TMDbID int `json:"tmdb_id"`
BangumiID int `json:"bangumi_id"`
DoubanID string `gorm:"column:douban_id;size:32" json:"douban_id,omitempty"`
TheTVDBID string `gorm:"column:thetvdb_id;size:64" json:"thetvdb_id,omitempty"`
Languages string `gorm:"size:64" json:"languages,omitempty"` // 逗号分隔的 ISO 639-1 代码,如 "zh,en"
Countries string `gorm:"size:128" json:"countries,omitempty"` // 逗号分隔的 ISO 3166-1,如 "CN,US"
Genres string `gorm:"type:text" json:"genres,omitempty"` // 逗号分隔的类型名,如 "Action,Animation"
NSFW bool `gorm:"default:false" json:"nsfw"`
// STRMURL is the indirection target for .strm files: when present the
// stream handler redirects to it instead of opening the local file.
+7
View File
@@ -61,5 +61,12 @@ func AllModels() []interface{} {
&StrmDirCache{},
&ScrapeTask{},
&EmbyMount{},
&ReaderBookSource{},
&ReaderSourceState{},
&ReaderBook{},
&ReaderChapter{},
&ReaderReplaceRule{},
&ReaderProfile{},
&ReaderBookGroups{},
}
}
+14 -17
View File
@@ -53,21 +53,18 @@ type PlaylistItem struct {
// AllowedLibraryIDs is a JSON array of library UUIDs (empty = all).
type PlayProfile struct {
Base
UserID string `gorm:"index;size:36;not null" json:"user_id"`
Name string `gorm:"size:64;not null" json:"name"`
IsDefault bool `gorm:"default:false" json:"is_default"`
ContentRatingLimit string `gorm:"size:16" json:"content_rating_limit,omitempty"`
AllowAdult bool `gorm:"default:false" json:"allow_adult"`
RequirePIN bool `gorm:"default:false" json:"require_pin"`
PINHash string `gorm:"size:128" json:"-"`
PreferredSubtitleLang string `gorm:"size:16" json:"preferred_subtitle_lang,omitempty"`
PreferredAudioLang string `gorm:"size:16" json:"preferred_audio_lang,omitempty"`
AutoplayNext bool `gorm:"default:true" json:"autoplay_next"`
SkipIntro bool `gorm:"default:false" json:"skip_intro"`
// SegmentSource 决定片头/片尾数据从哪来:auto(优先文件内嵌章节,没有可用
// 章节时回落到 TheIntroDB)/ theintrodb / ffprobe。空值按 auto 处理。
SegmentSource string `gorm:"size:16;default:'auto'" json:"segment_source,omitempty"`
AllowedLibraryIDs string `gorm:"type:text;default:'[]'" json:"allowed_library_ids"`
TotalWatchTime int64 `gorm:"default:0" json:"total_watch_time"`
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
UserID string `gorm:"index;size:36;not null" json:"user_id"`
Name string `gorm:"size:64;not null" json:"name"`
IsDefault bool `gorm:"default:false" json:"is_default"`
ContentRatingLimit string `gorm:"size:16" json:"content_rating_limit,omitempty"`
AllowAdult bool `gorm:"default:false" json:"allow_adult"`
RequirePIN bool `gorm:"default:false" json:"require_pin"`
PINHash string `gorm:"size:128" json:"-"`
PreferredSubtitleLang string `gorm:"size:16" json:"preferred_subtitle_lang,omitempty"`
PreferredAudioLang string `gorm:"size:16" json:"preferred_audio_lang,omitempty"`
AutoplayNext bool `gorm:"default:true" json:"autoplay_next"`
SkipIntro bool `gorm:"default:false" json:"skip_intro"`
AllowedLibraryIDs string `gorm:"type:text;default:'[]'" json:"allowed_library_ids"`
TotalWatchTime int64 `gorm:"default:0" json:"total_watch_time"`
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
}
+145
View File
@@ -0,0 +1,145 @@
// Package model — 阅读(legado 书源兼容)子系统数据模型。
// 字段语义对齐 legado 的 BookSource / Book / BookChapter / ReplaceRule 实体;
// 阅读进度沿用 legado 的做法直接挂在书籍上(durChapter* 字段)。
package model
import (
"time"
)
// ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。
type ReaderBookSource struct {
Base
Name string `gorm:"type:varchar(255);index" json:"name"`
GroupName string `gorm:"type:varchar(255);index" json:"group"`
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频
SourceURL string `gorm:"type:varchar(512);index" json:"source_url"`
RawJSON string `gorm:"type:text" json:"-"`
Enabled bool `gorm:"default:true" json:"enabled"`
EnabledExplore bool `gorm:"default:true" json:"enabled_explore"`
CustomOrder int `json:"custom_order"`
Weight int `json:"weight"`
ConcurrentRate string `gorm:"type:varchar(64)" json:"concurrent_rate"`
Header string `gorm:"type:text" json:"header"` // 书源级请求头 JSON
Comment string `gorm:"type:text" json:"comment"`
Variables string `gorm:"type:text" json:"variables"` // source 变量 JSON
LastUpdateTime int64 `json:"last_update_time"`
LastCheckAt *time.Time `json:"last_check_at"`
RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms)
// HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。
HasLogin bool `gorm:"-" json:"has_login"`
// NeedsBrowser 是否依赖 WebView/无头浏览器(webView 选项或 webjs 规则)。
// 服务端没有浏览器,这类源注定不可用,列表接口按需计算让前端能提前提示。
NeedsBrowser bool `gorm:"-" json:"needs_browser"`
}
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
// sourceVariable / userInfo(登录信息)/ loginHeader 与 CookieStore。
// 与书源分表存放,避免每次导入更新书源时把用户登录态覆盖掉。
type ReaderSourceState struct {
Base
SourceURL string `gorm:"type:varchar(512);uniqueIndex" json:"source_url"`
Variable string `gorm:"type:text" json:"variable"` // source.getVariable/setVariable
LoginInfo string `gorm:"type:text" json:"login_info"` // source.getLoginInfo/putLoginInfo(登录表单 JSON)
LoginHeader string `gorm:"type:text" json:"login_header"` // source 登录请求头 JSON
Cookies string `gorm:"type:text" json:"cookies"` // JSON: domain → "k=v; k=v"
}
// ReaderBook 书架条目(含阅读进度,对应 legado Book)。
type ReaderBook struct {
Base
UserID string `gorm:"type:varchar(36);index" json:"user_id"`
Origin string `gorm:"type:varchar(512)" json:"origin"` // 书源 URL
OriginName string `gorm:"type:varchar(255)" json:"origin_name"`
BookURL string `gorm:"type:varchar(512);index" json:"book_url"`
TocURL string `gorm:"type:varchar(512)" json:"toc_url"`
Name string `gorm:"type:varchar(255)" json:"name"`
Author string `gorm:"type:varchar(255)" json:"author"`
Kind string `gorm:"type:varchar(255)" json:"kind"`
CoverURL string `gorm:"type:varchar(512)" json:"cover_url"`
Intro string `gorm:"type:text" json:"intro"`
Charset string `gorm:"type:varchar(32)" json:"charset"`
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片
LatestChapterTitle string `gorm:"type:varchar(512)" json:"latest_chapter_title"`
// LatestChapterTime 最后一次「目录末尾章节发生变化」的时间(毫秒)。
// 对应 legado Book.latestChapterTime:书架「最近更新」排序与「更新时间」展示都读它。
// 抓目录时发现末章标题与已存值不同才刷新;本地导入书籍不参与。
LatestChapterTime int64 `json:"latest_chapter_time"`
TotalChapterNum int `json:"total_chapter_num"`
DurChapterIndex int `json:"dur_chapter_index"`
DurChapterPos int `json:"dur_chapter_pos"`
DurChapterTitle string `gorm:"type:varchar(512)" json:"dur_chapter_title"`
DurChapterTime int64 `json:"dur_chapter_time"`
Order int `json:"order"`
Variable string `gorm:"type:text" json:"variable"`
// LocalPath 本地导入书籍的位置:默认是 data/reader/local 下的文件名
// (如 "<id>.txt");LocalExternal 为真时是服务器上的绝对路径。
// 为空表示来自网络书源。不对外暴露路径,前端用 is_local 判断。
LocalPath string `gorm:"type:varchar(255)" json:"-"`
// LocalExternal 为真表示原地引用服务器上已有的文件/目录(管理员在导入时选定),
// 移出书架只解除引用,不删除源文件;为假表示 data/reader/local 下的托管副本。
LocalExternal bool `json:"local_external"`
// IsLocal 是否本地导入书籍,列表接口按需计算,不落库。
IsLocal bool `gorm:"-" json:"is_local"`
// 听书(音频源)跳过片头/片尾秒数,对应 legado Book.getOpenCredits/getCloseCredits。
// 0 表示不跳过。仅对音频和视频源生效。
OpenCredits int `gorm:"default:0" json:"open_credits"`
CloseCredits int `gorm:"default:0" json:"close_credits"`
}
// ReaderChapter 章节缓存(对应 legado BookChapter)。
type ReaderChapter struct {
Base
BookID string `gorm:"type:varchar(36);uniqueIndex:idx_reader_book_chapter" json:"book_id"`
Index int `gorm:"uniqueIndex:idx_reader_book_chapter" json:"index"`
URL string `gorm:"type:varchar(512)" json:"url"`
Title string `gorm:"type:varchar(512)" json:"title"`
IsVolume bool `json:"is_volume"`
Tag string `gorm:"type:varchar(255)" json:"tag"`
}
// ReaderReplaceRule 替换净化规则(对应 legado ReplaceRule)。
type ReaderReplaceRule struct {
Base
UserID string `gorm:"type:varchar(36);index" json:"user_id"`
Name string `gorm:"type:varchar(255)" json:"name"`
GroupName string `gorm:"type:varchar(255);index" json:"group"`
Pattern string `gorm:"type:text" json:"pattern"`
Replacement string `gorm:"type:text" json:"replacement"`
Scope string `gorm:"type:varchar(255)" json:"scope"`
ScopeTitle bool `json:"scope_title"`
ScopeContent bool `gorm:"default:true" json:"scope_content"`
ExcludeScope string `gorm:"type:varchar(255)" json:"exclude_scope"`
IsEnabled bool `gorm:"default:true" json:"is_enabled"`
IsRegex bool `gorm:"default:true" json:"is_regex"`
TimeoutMillisecond int64 `gorm:"default:3000" json:"timeout_millisecond"`
Order int `json:"order"`
}
// ReaderProfile 阅读器偏好(每个用户一条)。
//
// 主题 / 排版 / 听书 / 书架展示这些设置原先只存在浏览器 localStorage,属于设备级:
// 换设备或换浏览器就丢。这里按用户落库作为权威来源,前端 localStorage 退化成首屏缓存。
// 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不在这里同步。
type ReaderProfile struct {
Base
UserID string `gorm:"type:varchar(36);uniqueIndex" json:"user_id"`
ThemeID string `gorm:"type:varchar(32)" json:"theme_id"`
Night bool `json:"night"`
PageMode string `gorm:"type:varchar(16)" json:"page_mode"` // page 翻页 / scroll 滚动
FontSize int `json:"font_size"`
LineHeight float64 `json:"line_height"`
ParagraphSpacing int `json:"paragraph_spacing"`
// 听书偏好(对应 legado AudioPlay.playSpeed 与 AppConfig.ttsTimer)
AudioSpeed float64 `json:"audio_speed"`
AudioTimerMinutes int `json:"audio_timer_minutes"`
// 书架展示偏好(对应 legado 的书架设置)
ShelfLayout string `gorm:"type:varchar(16)" json:"shelf_layout"` // grid / list / compact
ShelfGridColumns int `json:"shelf_grid_columns"` // 0 表示自适应
ShelfSort string `gorm:"type:varchar(16)" json:"shelf_sort"`
ShelfShowUnread bool `json:"shelf_show_unread"`
ShelfShowUpdateTime bool `json:"shelf_show_update_time"`
}
+125
View File
@@ -0,0 +1,125 @@
package model
import (
"encoding/json"
"strings"
)
// 书架分组(对应 legado 的 BookGroup)。
//
// 与影视模块的媒体库标签(LibraryTagSet)同构:组名 → 成员 ID 列表,一个成员只归一个组。
// 区别只是归属对象从媒体库换成了书籍,因此前端可以照搬那套标签栏 / 管理弹窗的交互。
const (
// MaxBookGroups 是单个用户可创建的分组数量上限,避免恶意写入过大的 JSON。
MaxBookGroups = 50
// MaxBookGroupNameLen 是单个分组名的最大字符长度(按 rune 计数)。
MaxBookGroupNameLen = 24
)
// BookGroupSet 是一个书架分组:组名 + 组内书籍 ID(顺序即组内展示顺序)。
type BookGroupSet struct {
Name string `json:"name"`
BookIDs []string `json:"book_ids"`
}
// ReaderBookGroups 是某个用户的书架分组集合,每个用户一条记录。
//
// 分组是「整份替换」语义(前端一次 PUT 提交完整分组列表),所以直接存成一份 JSON;
// 与阅读器偏好(ReaderProfile)分表存放,两条写入路径互不干扰:
// 先建分组、后调偏好时不会因为整行 Save 把对方的列写成零值。
type ReaderBookGroups struct {
Base
UserID string `gorm:"type:varchar(36);uniqueIndex" json:"user_id"`
// Groups 是 []BookGroupSet 的 JSON 文本;空字符串表示没有分组。
Groups string `gorm:"type:text" json:"-"`
}
// DecodeBookGroups 解析 Groups 字段,忽略损坏的数据。
func (r *ReaderBookGroups) DecodeBookGroups() []BookGroupSet {
if r == nil || strings.TrimSpace(r.Groups) == "" {
return nil
}
var groups []BookGroupSet
if err := json.Unmarshal([]byte(r.Groups), &groups); err != nil {
return nil
}
return NormalizeBookGroups(groups)
}
// EncodeBookGroups 把分组集合序列化为可写入 Groups 字段的 JSON 文本。
// 空集合序列化为空字符串,便于用零值表达「没有分组」。
func EncodeBookGroups(groups []BookGroupSet) (string, error) {
if len(groups) == 0 {
return "", nil
}
raw, err := json.Marshal(groups)
if err != nil {
return "", err
}
return string(raw), nil
}
// NormalizeBookGroups 清洗分组集合:去掉空名分组、合并重名分组、去掉组内重复的
// 书籍 ID,并保持传入顺序。不在这里做「一本书只归一个组」的收敛——那需要知道
// 书籍是否存在,属于服务层(SetBookGroups)的职责。
func NormalizeBookGroups(groups []BookGroupSet) []BookGroupSet {
if len(groups) == 0 {
return nil
}
out := make([]BookGroupSet, 0, len(groups))
indexByName := make(map[string]int, len(groups))
for _, group := range groups {
name := TruncateBookGroupName(group.Name)
if name == "" {
continue
}
if len(out) >= MaxBookGroups {
break
}
key := strings.ToLower(name)
pos, exists := indexByName[key]
if !exists {
out = append(out, BookGroupSet{Name: name, BookIDs: []string{}})
pos = len(out) - 1
indexByName[key] = pos
}
seen := make(map[string]struct{}, len(out[pos].BookIDs))
for _, id := range out[pos].BookIDs {
seen[id] = struct{}{}
}
for _, id := range group.BookIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out[pos].BookIDs = append(out[pos].BookIDs, trimmed)
}
}
if len(out) == 0 {
return nil
}
for i := range out {
if out[i].BookIDs == nil {
out[i].BookIDs = []string{}
}
}
return out
}
// TruncateBookGroupName 去掉首尾空白并按 rune 截断到长度上限。
func TruncateBookGroupName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
runes := []rune(name)
if len(runes) > MaxBookGroupNameLen {
runes = runes[:MaxBookGroupNameLen]
}
return strings.TrimSpace(string(runes))
}
+84
View File
@@ -0,0 +1,84 @@
package model
import (
"strings"
"testing"
)
// 书架分组的清洗与编解码回归测试(与 library_tag_test.go 同构)。
func TestNormalizeBookGroupsMergesDuplicatesAndTrims(t *testing.T) {
groups := []BookGroupSet{
{Name: " 科幻 ", BookIDs: []string{"a", "b"}},
{Name: "科幻", BookIDs: []string{"b", "c"}},
{Name: " ", BookIDs: []string{"d"}},
{Name: "在读", BookIDs: []string{"", "e", "e"}},
}
got := NormalizeBookGroups(groups)
if len(got) != 2 {
t.Fatalf("NormalizeBookGroups len = %d, want 2 (%#v)", len(got), got)
}
if got[0].Name != "科幻" || strings.Join(got[0].BookIDs, ",") != "a,b,c" {
t.Fatalf("首个分组 = %#v,期望 科幻[a,b,c]", got[0])
}
if got[1].Name != "在读" || strings.Join(got[1].BookIDs, ",") != "e" {
t.Fatalf("第二个分组 = %#v,期望 在读[e]", got[1])
}
}
func TestNormalizeBookGroupsCapsCountAndNameLength(t *testing.T) {
long := strings.Repeat("长", MaxBookGroupNameLen+10)
got := NormalizeBookGroups([]BookGroupSet{{Name: long}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
if runes := []rune(got[0].Name); len(runes) != MaxBookGroupNameLen {
t.Fatalf("分组名长度 = %d,期望 %d", len(runes), MaxBookGroupNameLen)
}
many := make([]BookGroupSet, 0, MaxBookGroups+5)
for i := 0; i < MaxBookGroups+5; i++ {
many = append(many, BookGroupSet{Name: string(rune('a' + i))})
}
if capped := NormalizeBookGroups(many); len(capped) != MaxBookGroups {
t.Fatalf("capped len = %d, want %d", len(capped), MaxBookGroups)
}
}
func TestNormalizeBookGroupsKeepsEmptyGroups(t *testing.T) {
got := NormalizeBookGroups([]BookGroupSet{{Name: "空组"}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
// 空分组的 BookIDs 应该是空数组而不是 nil,前端直接遍历时不会炸
if got[0].BookIDs == nil || len(got[0].BookIDs) != 0 {
t.Fatalf("BookIDs = %#v,期望空数组", got[0].BookIDs)
}
}
func TestEncodeDecodeBookGroupsRoundTrip(t *testing.T) {
if encoded, err := EncodeBookGroups(nil); err != nil || encoded != "" {
t.Fatalf("EncodeBookGroups(nil) = %q, %v; want \"\", nil", encoded, err)
}
raw, err := EncodeBookGroups([]BookGroupSet{{Name: "科幻", BookIDs: []string{"b-1"}}})
if err != nil {
t.Fatalf("EncodeBookGroups: %v", err)
}
row := &ReaderBookGroups{Groups: raw}
decoded := row.DecodeBookGroups()
if len(decoded) != 1 || decoded[0].Name != "科幻" || decoded[0].BookIDs[0] != "b-1" {
t.Fatalf("DecodeBookGroups = %#v", decoded)
}
row.Groups = "{not json"
if decoded := row.DecodeBookGroups(); decoded != nil {
t.Fatalf("损坏数据应返回 nil,实际 %#v", decoded)
}
// nil 接收者不应 panic(仓库层查不到记录时会返回 nil)
var nilRow *ReaderBookGroups
if decoded := nilRow.DecodeBookGroups(); decoded != nil {
t.Fatalf("nil 接收者应返回 nil,实际 %#v", decoded)
}
}
+2 -2
View File
@@ -37,8 +37,8 @@ type User struct {
// original / simplified / traditional。
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
// 网页播放器偏好按用户存储,切换媒体对象后继续沿用。
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
PlayerPlaybackRate float64 `gorm:"not null;default:1" json:"player_playback_rate"`
// 播放倍速不在此列:它只作用于当前视频,不进数据库。
PlayerVolume float64 `gorm:"not null;default:1" json:"player_volume"`
// PlayerVr360GuideSeen 记录用户是否已经看过 VR 全景播放的首次操作说明,
// 按用户保存:看过一次之后不再弹出。
PlayerVr360GuideSeen bool `gorm:"not null;default:false" json:"player_vr360_guide_seen"`
+25
View File
@@ -232,6 +232,31 @@ func (r *MediaRepository) ExistsSiblingWithTMDbID(ctx context.Context, m *model.
return count > 0
}
// ListSeasonSiblings returns other episodes in the same season as m.
// Prefers series_id; falls back to shared library+title+tm_db_id (anime scrape path).
func (r *MediaRepository) ListSeasonSiblings(ctx context.Context, m *model.Media) ([]model.Media, error) {
if r == nil || m == nil || m.SeasonNum <= 0 || m.ID == "" {
return nil, nil
}
query := r.db.WithContext(ctx).Model(&model.Media{}).
Where("season_num = ? AND id <> ?", m.SeasonNum, m.ID)
if seriesID := strings.TrimSpace(m.SeriesID); seriesID != "" {
query = query.Where("series_id = ?", seriesID)
} else {
libraryID := strings.TrimSpace(m.LibraryID)
title := strings.TrimSpace(m.Title)
if libraryID == "" || title == "" || m.TMDbID <= 0 {
return nil, nil
}
query = query.Where("library_id = ? AND title = ? AND tm_db_id = ?", libraryID, title, m.TMDbID)
}
var rows []model.Media
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
// ListByLibrary returns paginated media items for a library.
func (r *MediaRepository) ListByLibrary(ctx context.Context, libraryID string, offset, limit int) ([]model.Media, int64, error) {
return r.ListByLibraryFiltered(ctx, libraryID, offset, limit, MediaQueryFilter{IncludeNSFW: true})
@@ -332,6 +332,11 @@ func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.M
if episodeChanged {
updates["episode_num"] = incoming.EpisodeNum
}
// 半集的小数部分独立于整数集号比较:S01E11.5 重命名/新增/删除时都要落库,
// 否则「11」与「11.5」会被当成同一集折叠。
if existing.EpisodeFraction != incoming.EpisodeFraction {
updates["episode_fraction"] = incoming.EpisodeFraction
}
if strings.TrimSpace(existing.ScrapeStatus) == "no_match" && incoming.ScrapeStatus != "matched" && (seasonChanged || episodeChanged) {
updates["scrape_status"] = "pending"
}
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -84,3 +85,45 @@ func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.Med
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
}
// ListPrewarmCandidates returns media that should be refreshed from source:
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore).
// Recently played items come first so hot titles recover coverage sooner.
func (r *MediaSegmentRepository) ListPrewarmCandidates(
ctx context.Context,
source string,
missBefore, hitBefore time.Time,
limit int,
) ([]model.Media, error) {
if r == nil || limit <= 0 {
return nil, nil
}
rows := make([]model.Media, 0, limit)
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
err := r.db.WithContext(ctx).Raw(`
SELECT m.*
FROM media m
LEFT JOIN media_segment_fetches f
ON f.media_id = m.id AND f.source = ? AND f.deleted_at IS NULL
LEFT JOIN (
SELECT media_id, MAX(updated_at) AS last_played
FROM playback_histories
WHERE deleted_at IS NULL
GROUP BY media_id
) ph ON ph.media_id = m.id
WHERE m.deleted_at IS NULL
AND m.tm_db_id > 0
AND (
(m.season_num > 0 AND m.episode_num > 0)
OR (COALESCE(m.season_num, 0) = 0 AND COALESCE(m.episode_num, 0) = 0)
)
AND (
f.id IS NULL
OR (f.found = 0 AND f.fetched_at < ?)
OR (f.found = 1 AND f.fetched_at < ?)
)
ORDER BY ph.last_played DESC
LIMIT ?
`, source, missBefore, hitBefore, limit).Scan(&rows).Error
return rows, err
}
@@ -152,3 +152,56 @@ func TestUpsertFetchKeepsOneRowPerMediaAndSource(t *testing.T) {
t.Fatalf("ledger should be updated in place, got %#v", got)
}
}
func TestListSeasonSiblingsSharesLibraryTitleTMDb(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
eps := []*model.Media{
{Base: model.Base{ID: "a"}, LibraryID: "lib", Title: "Show", Path: "/a", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99},
{Base: model.Base{ID: "b"}, LibraryID: "lib", Title: "Show", Path: "/b", SeasonNum: 1, EpisodeNum: 2, TMDbID: 99},
{Base: model.Base{ID: "c"}, LibraryID: "lib", Title: "Show", Path: "/c", SeasonNum: 2, EpisodeNum: 1, TMDbID: 99},
}
for _, ep := range eps {
if err := repos.DB.Create(ep).Error; err != nil {
t.Fatal(err)
}
}
got, err := repos.Media.ListSeasonSiblings(ctx, eps[0])
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ID != "b" {
t.Fatalf("siblings = %#v, want only same-season ep b", got)
}
}
func TestListPrewarmCandidatesOrdersRecentPlaysFirst(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
now := time.Now()
for _, m := range []*model.Media{
{Base: model.Base{ID: "cold"}, Path: "/cold", TMDbID: 1},
{Base: model.Base{ID: "hot"}, Path: "/hot", TMDbID: 2},
} {
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
}
if err := repos.DB.Create(&model.PlaybackHistory{
Base: model.Base{ID: "ph1"}, UserID: "u", MediaID: "hot",
}).Error; err != nil {
t.Fatal(err)
}
got, err := repos.MediaSegment.ListPrewarmCandidates(
ctx, "theintrodb", now.Add(-time.Hour), now.Add(-time.Hour), 10,
)
if err != nil {
t.Fatal(err)
}
if len(got) < 2 {
t.Fatalf("candidates = %#v, want both", got)
}
if got[0].ID != "hot" {
t.Fatalf("first = %s, want hot (recently played)", got[0].ID)
}
}
+283
View File
@@ -0,0 +1,283 @@
// Package repository — 阅读子系统仓储(书源 / 书架 / 章节 / 替换规则)。
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// ReaderRepository 阅读子系统 GORM 封装。
type ReaderRepository struct {
db *gorm.DB
}
// ListSources 书源列表(按 customOrder 排序)。
func (r *ReaderRepository) ListSources(ctx context.Context) ([]model.ReaderBookSource, error) {
var out []model.ReaderBookSource
err := r.db.WithContext(ctx).Order("custom_order ASC, updated_at DESC").Find(&out).Error
return out, err
}
// GetSource 按 ID 取书源。
func (r *ReaderRepository) GetSource(ctx context.Context, id string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
if err := r.db.WithContext(ctx).First(&out, "id = ?", id).Error; err != nil {
return nil, err
}
return &out, nil
}
// GetSourceByURL 按书源 URL 取书源(导入去重用)。
func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
if err != nil {
return nil, err
}
return &out, nil
}
// CreateSource 新增书源。
func (r *ReaderRepository) CreateSource(ctx context.Context, src *model.ReaderBookSource) error {
return r.db.WithContext(ctx).Create(src).Error
}
// UpdateSource 更新书源字段。
func (r *ReaderRepository) UpdateSource(ctx context.Context, src *model.ReaderBookSource) error {
return r.db.WithContext(ctx).Save(src).Error
}
// DeleteSource 删除书源(连带清理其会话状态)。
func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
src := &model.ReaderBookSource{}
if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" {
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引:
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的
// First(默认排除软删行)查不到、Create 就会撞唯一约束,
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
// cookie / 登录态从此再也存不进去。这里必须硬删。
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
return err
}
}
return tx.Delete(&model.ReaderBookSource{}, "id = ?", id).Error
})
}
// GetSourceState 取书源会话状态;不存在返回 (nil, nil)。
func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string) (*model.ReaderSourceState, error) {
var out model.ReaderSourceState
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &out, nil
}
// SaveSourceState 覆盖保存书源会话状态(不存在则新建)。
func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error {
// 用 Unscoped 连软删行一起找:source_url 的唯一索引覆盖软删行,
// 只按未删行查会漏掉历史行,随后 Create 必然撞唯一约束,
// 结果就是该源的会话状态(含 cookie、登录态)永远保存失败。
var existing model.ReaderSourceState
err := r.db.WithContext(ctx).Unscoped().First(&existing, "source_url = ?", st.SourceURL).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.WithContext(ctx).Create(st).Error
}
if err != nil {
return err
}
st.ID = existing.ID
st.CreatedAt = existing.CreatedAt
if existing.DeletedAt.Valid {
// 历史行被软删过:连 deleted_at 一起写回,把它复活
st.DeletedAt = gorm.DeletedAt{}
return r.db.WithContext(ctx).Unscoped().Save(st).Error
}
return r.db.WithContext(ctx).Save(st).Error
}
// ListBooks 用户书架(按 order 排序)。
func (r *ReaderRepository) ListBooks(ctx context.Context, userID string) ([]model.ReaderBook, error) {
var out []model.ReaderBook
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("`order` ASC, updated_at DESC").Find(&out).Error
return out, err
}
// GetBook 按 ID 取书。
func (r *ReaderRepository) GetBook(ctx context.Context, id string) (*model.ReaderBook, error) {
var out model.ReaderBook
if err := r.db.WithContext(ctx).First(&out, "id = ?", id).Error; err != nil {
return nil, err
}
return &out, nil
}
// FindBookByURL 按用户 + 书源 + 书 URL 查书(加书架去重)。
func (r *ReaderRepository) FindBookByURL(ctx context.Context, userID, origin, bookURL string) (*model.ReaderBook, error) {
var out model.ReaderBook
err := r.db.WithContext(ctx).First(&out, "user_id = ? AND origin = ? AND book_url = ?", userID, origin, bookURL).Error
if err != nil {
return nil, err
}
return &out, nil
}
// CreateBook / UpdateBook / DeleteBook。
func (r *ReaderRepository) CreateBook(ctx context.Context, b *model.ReaderBook) error {
return r.db.WithContext(ctx).Create(b).Error
}
func (r *ReaderRepository) UpdateBook(ctx context.Context, b *model.ReaderBook) error {
return r.db.WithContext(ctx).Save(b).Error
}
func (r *ReaderRepository) DeleteBook(ctx context.Context, userID, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(&model.ReaderBook{}, "id = ? AND user_id = ?", id, userID).Error; err != nil {
return err
}
return tx.Delete(&model.ReaderChapter{}, "book_id = ?", id).Error
})
}
// ReplaceChapters 覆盖式刷新章节列表。
func (r *ReaderRepository) ReplaceChapters(ctx context.Context, bookID string, chapters []model.ReaderChapter) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 章节是纯缓存(软删会留下行,撞上 (book_id, index) 唯一索引),这里物理删除
if err := tx.Unscoped().Delete(&model.ReaderChapter{}, "book_id = ?", bookID).Error; err != nil {
return err
}
if len(chapters) == 0 {
return nil
}
return tx.CreateInBatches(chapters, 500).Error
})
}
// ListChapters 按序取章节。
func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) {
var out []model.ReaderChapter
err := r.db.WithContext(ctx).Where("book_id = ?", bookID).Order("`index` ASC").Find(&out).Error
return out, err
}
// CountChaptersByBook 一次统计多本书已缓存的章节数(书架显示未读章数用,避免逐本查询)。
// 没有目录缓存的书籍不会出现在返回结果里。
func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []string) (map[string]int, error) {
out := make(map[string]int, len(bookIDs))
if len(bookIDs) == 0 {
return out, nil
}
var rows []struct {
BookID string
Total int
}
err := r.db.WithContext(ctx).
Model(&model.ReaderChapter{}).
Select("book_id, COUNT(*) AS total").
Where("book_id IN ?", bookIDs).
Group("book_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
out[row.BookID] = row.Total
}
return out, nil
}
// GetChapter 取指定章节。
func (r *ReaderRepository) GetChapter(ctx context.Context, bookID string, index int) (*model.ReaderChapter, error) {
var out model.ReaderChapter
if err := r.db.WithContext(ctx).First(&out, "book_id = ? AND `index` = ?", bookID, index).Error; err != nil {
return nil, err
}
return &out, nil
}
// GetReaderProfile 取用户阅读器偏好;没有记录时返回 (nil, nil),
// 由调用方决定是否用前端上送的当前值播种。
func (r *ReaderRepository) GetReaderProfile(ctx context.Context, userID string) (*model.ReaderProfile, error) {
var out model.ReaderProfile
err := r.db.WithContext(ctx).First(&out, "user_id = ?", userID).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &out, nil
}
// SaveReaderProfile 覆盖保存用户阅读器偏好(不存在则新建)。
func (r *ReaderRepository) SaveReaderProfile(ctx context.Context, p *model.ReaderProfile) error {
var existing model.ReaderProfile
err := r.db.WithContext(ctx).First(&existing, "user_id = ?", p.UserID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.WithContext(ctx).Create(p).Error
}
if err != nil {
return err
}
p.ID = existing.ID
p.CreatedAt = existing.CreatedAt
return r.db.WithContext(ctx).Save(p).Error
}
// GetBookGroups 取用户的书架分组;没有记录时返回 (nil, nil)。
func (r *ReaderRepository) GetBookGroups(ctx context.Context, userID string) (*model.ReaderBookGroups, error) {
var out model.ReaderBookGroups
err := r.db.WithContext(ctx).First(&out, "user_id = ?", userID).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &out, nil
}
// SaveBookGroups 覆盖保存用户的书架分组(不存在则新建)。
func (r *ReaderRepository) SaveBookGroups(ctx context.Context, row *model.ReaderBookGroups) error {
var existing model.ReaderBookGroups
err := r.db.WithContext(ctx).First(&existing, "user_id = ?", row.UserID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.WithContext(ctx).Create(row).Error
}
if err != nil {
return err
}
row.ID = existing.ID
row.CreatedAt = existing.CreatedAt
return r.db.WithContext(ctx).Save(row).Error
}
// ListReplaceRules 用户替换规则(按 order 排序)。
func (r *ReaderRepository) ListReplaceRules(ctx context.Context, userID string) ([]model.ReaderReplaceRule, error) {
var out []model.ReaderReplaceRule
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("`order` DESC, created_at ASC").Find(&out).Error
return out, err
}
// CreateReplaceRule / UpdateReplaceRule / DeleteReplaceRule。
func (r *ReaderRepository) CreateReplaceRule(ctx context.Context, rule *model.ReaderReplaceRule) error {
return r.db.WithContext(ctx).Create(rule).Error
}
func (r *ReaderRepository) UpdateReplaceRule(ctx context.Context, rule *model.ReaderReplaceRule) error {
return r.db.WithContext(ctx).Save(rule).Error
}
func (r *ReaderRepository) DeleteReplaceRule(ctx context.Context, userID, id string) error {
return r.db.WithContext(ctx).Delete(&model.ReaderReplaceRule{}, "id = ? AND user_id = ?", id, userID).Error
}
+207
View File
@@ -0,0 +1,207 @@
package repository
import (
"errors"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
)
func newReaderTestRepo(t *testing.T) *ReaderRepository {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
return New(db).Reader
}
// TestSourceStateRoundTrip 验证会话状态按书源 URL 存取。
func TestSourceStateRoundTrip(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
// 不存在时返回 (nil, nil),调用方据此走默认值
got, err := repo.GetSourceState(ctx, "https://a.example.com")
if err != nil || got != nil {
t.Fatalf("首次读取应为空: got=%v err=%v", got, err)
}
st := &model.ReaderSourceState{
SourceURL: "https://a.example.com",
Variable: `{"线路":"v1"}`,
LoginInfo: `{"邮箱":"u@e.com"}`,
LoginHeader: `{"X-Token":"t"}`,
Cookies: `{"example.com":"qttoken=abc"}`,
}
if err := repo.SaveSourceState(ctx, st); err != nil {
t.Fatal(err)
}
got, err = repo.GetSourceState(ctx, "https://a.example.com")
if err != nil {
t.Fatal(err)
}
if got.LoginInfo != st.LoginInfo || got.Variable != st.Variable {
t.Fatalf("读回不一致: %+v", got)
}
// 再次保存应为更新而非插入(同 URL 唯一)
st.LoginInfo = `{"邮箱":"new@e.com"}`
if err := repo.SaveSourceState(ctx, st); err != nil {
t.Fatal(err)
}
var count int64
if err := repo.db.Model(&model.ReaderSourceState{}).
Where("source_url = ?", "https://a.example.com").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("同一书源应只有一行状态,实际 %d", count)
}
got, _ = repo.GetSourceState(ctx, "https://a.example.com")
if got.LoginInfo != `{"邮箱":"new@e.com"}` {
t.Fatalf("更新未生效: %+v", got)
}
}
// TestDeleteSourceAlsoClearsState 删除书源应连带清理登录态。
func TestDeleteSourceAlsoClearsState(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
src := &model.ReaderBookSource{Name: "源", SourceURL: "https://b.example.com", Enabled: true}
if err := repo.CreateSource(ctx, src); err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: "https://b.example.com", LoginInfo: `{"a":"b"}`,
}); err != nil {
t.Fatal(err)
}
if err := repo.DeleteSource(ctx, src.ID); err != nil {
t.Fatal(err)
}
st, err := repo.GetSourceState(ctx, "https://b.example.com")
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal(err)
}
if st != nil {
t.Fatalf("书源删除后登录态应一并清理,实际仍在: %+v", st)
}
}
// TestSaveSourceStateAfterSourceDeleted 回归:书源删除后,同一 source_url 的状态必须还能存。
//
// source_url 上的唯一索引覆盖软删行,而 DeleteSource 曾经只做软删:
// 之后 SaveSourceState 的 First(默认排除软删行)查不到 → Create 撞唯一约束,
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
// cookie / 登录态从此永远存不进去(删过或重导入过的书源必现)。
func TestSaveSourceStateAfterSourceDeleted(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
const url = "https://d.example.com"
src := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
if err := repo.CreateSource(ctx, src); err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: url, Cookies: `{"example.com":"a=1"}`,
}); err != nil {
t.Fatal(err)
}
if err := repo.DeleteSource(ctx, src.ID); err != nil {
t.Fatal(err)
}
// 重新导入同 URL 的书源,再存一次状态
again := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
if err := repo.CreateSource(ctx, again); err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: url, Cookies: `{"example.com":"a=2"}`,
}); err != nil {
t.Fatalf("书源删除/重导入后状态必须还能保存: %v", err)
}
st, err := repo.GetSourceState(ctx, url)
if err != nil {
t.Fatal(err)
}
if st == nil || st.Cookies != `{"example.com":"a=2"}` {
t.Fatalf("状态未写入: %+v", st)
}
}
// TestSaveSourceStateRevivesSoftDeletedRow 回归:老数据里已被软删的状态行要能复活。
//
// 修复前 DeleteSource 是软删,历史库里可能已经躺着软删行;
// 保存时必须把它救活,不能去 INSERT 撞唯一约束。
func TestSaveSourceStateRevivesSoftDeletedRow(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
const url = "https://e.example.com"
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: url, Cookies: `{"example.com":"old=1"}`,
}); err != nil {
t.Fatal(err)
}
// 造出老版本 DeleteSource 留下的样子:行还在但被软删
if err := repo.db.Exec(
"update reader_source_states set deleted_at = ? where source_url = ?",
time.Now(), url).Error; err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: url, Cookies: `{"example.com":"new=2"}`,
}); err != nil {
t.Fatalf("软删过的历史行应被复活,而不是撞唯一约束: %v", err)
}
st, err := repo.GetSourceState(ctx, url)
if err != nil {
t.Fatal(err)
}
if st == nil || st.Cookies != `{"example.com":"new=2"}` {
t.Fatalf("复活后状态未写入: %+v", st)
}
}
// TestImportUpdateKeepsSourceState 重新导入书源(更新 RawJSON)不应丢失登录态。
func TestImportUpdateKeepsSourceState(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
src := &model.ReaderBookSource{Name: "源", SourceURL: "https://c.example.com", RawJSON: `{"v":1}`}
if err := repo.CreateSource(ctx, src); err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: "https://c.example.com", LoginInfo: `{"k":"keep"}`,
}); err != nil {
t.Fatal(err)
}
// 模拟导入更新:只改书源自身字段
src.RawJSON = `{"v":2}`
if err := repo.UpdateSource(ctx, src); err != nil {
t.Fatal(err)
}
st, err := repo.GetSourceState(ctx, "https://c.example.com")
if err != nil {
t.Fatal(err)
}
if st == nil || st.LoginInfo != `{"k":"keep"}` {
t.Fatalf("更新书源后登录态丢失: %+v", st)
}
}
+2
View File
@@ -36,6 +36,7 @@ type Container struct {
StrmDirCache *StrmDirCacheRepository
ScrapeTask *ScrapeTaskRepository
EmbyMount *EmbyMountRepository
Reader *ReaderRepository
}
// New 将每个 repository 连接到单个 *gorm.DB。
@@ -68,5 +69,6 @@ func New(db *gorm.DB) *Container {
StrmDirCache: &StrmDirCacheRepository{db: db},
ScrapeTask: &ScrapeTaskRepository{db: db},
EmbyMount: &EmbyMountRepository{db: db},
Reader: &ReaderRepository{db: db},
}
}
+23 -9
View File
@@ -128,16 +128,16 @@ func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter
strings.Contains(contentType, "application/vnd.apple.mpegurl")
if !isPlaylist {
// Content-Type 缺失时用前 4KB 内容嗅探是否为播放列表;无论结果如何
// 都要把已读前缀接回 body,避免分片/播放列表丢开头字节。
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if readErr != nil {
return readErr
}
if strings.HasPrefix(strings.TrimSpace(string(body)), "#EXTM3U") {
isPlaylist = true
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
} else {
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
}
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
}
copyUpstreamHeaders(w, resp, isPlaylist)
@@ -195,9 +195,17 @@ func (p *Cloud115HLSProxy) storeSession(session *cloud115HLSSession) {
}
}
if len(p.sessions) >= cloud115HLSMaxSessions {
for id := range p.sessions {
delete(p.sessions, id)
break
// ExpiresAt 恒为「最近一次活跃时间 + TTL」(lookup 会滑动续期),
// 因此最小者即最久未活跃的会话,淘汰它对正在播放的影响最小。
evictID := ""
var evictAt time.Time
for id, existing := range p.sessions {
if evictID == "" || existing.ExpiresAt.Before(evictAt) {
evictID, evictAt = id, existing.ExpiresAt
}
}
if evictID != "" {
delete(p.sessions, evictID)
}
}
session.ExpiresAt = now.Add(cloud115HLSSessionTTL)
@@ -210,9 +218,15 @@ func (p *Cloud115HLSProxy) lookup(sessionID, key string) (*cloud115HLSSession, s
}
p.mu.Lock()
session := p.sessions[sessionID]
if session != nil && time.Now().After(session.ExpiresAt) {
delete(p.sessions, sessionID)
session = nil
if session != nil {
if time.Now().After(session.ExpiresAt) {
delete(p.sessions, sessionID)
session = nil
} else {
// 滑动续期:VOD 点播整个播放期间只有分片请求,master 只在起播时取
// 一次,不续期的话会话会在 TTL 后被删,长视频播放到一半必然断流。
session.ExpiresAt = time.Now().Add(cloud115HLSSessionTTL)
}
}
p.mu.Unlock()
if session == nil {
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -56,6 +57,67 @@ https://cpats01.115.com/seg.ts?x=1
}
}
// 长视频 VOD 播放期间只有分片请求经过 lookup,会话必须随活动滑动续期,
// 否则固定 30 分钟 TTL 一到就会在中途 404 断流。
func TestCloud115HLSProxyLookupRenewsSession(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
}
proxy.storeSession(session)
// 模拟播放进行到第 29 分钟:距过期只剩 1 分钟。
session.ExpiresAt = time.Now().Add(time.Minute)
if _, _, ok := proxy.lookup(session.ID, "e1"); !ok {
t.Fatal("active session should still resolve before expiry")
}
if remaining := time.Until(session.ExpiresAt); remaining < cloud115HLSSessionTTL-10*time.Second {
t.Fatalf("session not renewed on activity, remaining = %v", remaining)
}
// 真正过期(期间无任何活动)的会话仍要被清理。
session.ExpiresAt = time.Now().Add(-time.Minute)
if _, _, ok := proxy.lookup(session.ID, "e1"); ok {
t.Fatal("expired session should not resolve")
}
proxy.mu.Lock()
_, exists := proxy.sessions[session.ID]
proxy.mu.Unlock()
if exists {
t.Fatal("expired session should be evicted from the map")
}
}
// 满员驱逐必须淘汰最久未活跃的会话(ExpiresAt = 最近活跃 + TTL,最小者即
// 最久未活跃),而不是随机挑一个——随机驱逐可能正好杀掉正在播放的会话。
func TestCloud115HLSProxyEvictsLeastRecentlyActiveSession(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
first := &cloud115HLSSession{ID: "active", MediaID: "media-1", entries: map[string]string{}}
proxy.storeSession(first)
for i := 0; i < cloud115HLSMaxSessions-1; i++ {
proxy.storeSession(&cloud115HLSSession{ID: fmt.Sprintf("s-%03d", i), MediaID: "media-1", entries: map[string]string{}})
}
// 人为拉开活跃时间差,避免依赖时钟精度:active 最近活跃,s-000 最久未活跃。
base := time.Now()
first.ExpiresAt = base.Add(cloud115HLSSessionTTL)
proxy.sessions["s-000"].ExpiresAt = base.Add(cloud115HLSSessionTTL - time.Hour)
proxy.storeSession(&cloud115HLSSession{ID: "newcomer", MediaID: "media-1", entries: map[string]string{}})
proxy.mu.Lock()
_, activeExists := proxy.sessions["active"]
_, staleExists := proxy.sessions["s-000"]
proxy.mu.Unlock()
if !activeExists {
t.Fatal("recently active session must survive eviction")
}
if staleExists {
t.Fatal("least recently active session should have been evicted")
}
}
func TestIsAllowed115UpstreamHost(t *testing.T) {
for _, host := range []string{"videoplay.115.com", "cpats01.115.com", "cdn.115cdn.net"} {
if !isAllowed115UpstreamHost(host) {
+12
View File
@@ -220,6 +220,11 @@ func (s *Cloud115PlaybackService) ResolveCloud115URL(ctx context.Context, mediaI
return strings.TrimSpace(item.URL), data, nil
}
// cloud115PushStateRetention pushState 条目保留上限。成功条目 3 小时内用于
// 去重;超过一倍余量即为死数据,触发转码请求时顺带清理,防止 map 随观看过的
// 条目数无限增长。
const cloud115PushStateRetention = 6 * time.Hour
func (s *Cloud115PlaybackService) ensureCloudTranscode(
ctx context.Context,
accountID, pickCode string,
@@ -230,6 +235,13 @@ func (s *Cloud115PlaybackService) ensureCloudTranscode(
now := time.Now()
s.mu.Lock()
if len(s.pushState) > 0 {
for k, v := range s.pushState {
if now.Sub(v.at) > cloud115PushStateRetention {
delete(s.pushState, k)
}
}
}
if last, ok := s.pushState[key]; ok {
ttl := 2 * time.Minute
if last.success {
+15 -59
View File
@@ -12,96 +12,52 @@
package service
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"strings"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
)
// encPrefix tags ciphertext rows so we can tell them apart from legacy
// plaintext values.
const encPrefix = "enc:v1:"
// plaintext values. Kept as an alias of the shared helper's prefix so both
// implementations stay wire-compatible.
const encPrefix = helper.EncPrefix
// CryptoService wraps an AES-GCM cipher derived from a stable per-install
// secret (the JWT secret).
//
// The cipher itself lives in helper.SecretCipher so lower layers (e.g. the
// reader subsystem, which cannot import this package) can share one
// implementation; this type keeps the service-layer logging and API.
type CryptoService struct {
log *zap.Logger
aead cipher.AEAD
log *zap.Logger
cipher *helper.SecretCipher
}
// NewCryptoService derives a 256-bit key from the given secret via
// SHA-256 and constructs an AES-GCM AEAD. Empty secrets yield a service
// whose Encrypt/Decrypt methods are pass-throughs (used in unit tests).
func NewCryptoService(secret string, log *zap.Logger) *CryptoService {
c := &CryptoService{log: log}
if strings.TrimSpace(secret) == "" {
return c
}
sum := sha256.Sum256([]byte(secret))
block, err := aes.NewCipher(sum[:])
if err != nil {
log.Error("crypto: aes.NewCipher", zap.Error(err))
return c
}
aead, err := cipher.NewGCM(block)
if err != nil {
log.Error("crypto: cipher.NewGCM", zap.Error(err))
return c
}
c.aead = aead
return c
return &CryptoService{log: log, cipher: helper.NewSecretCipher(secret)}
}
// Encrypt returns the base64-encoded ciphertext (with prefix) for plain.
// Empty inputs round-trip unchanged.
func (c *CryptoService) Encrypt(plain string) string {
if plain == "" || c.aead == nil {
return plain
}
if strings.HasPrefix(plain, encPrefix) {
return plain
}
nonce := make([]byte, c.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return plain
}
cipherBytes := c.aead.Seal(nonce, nonce, []byte(plain), nil)
return encPrefix + base64.StdEncoding.EncodeToString(cipherBytes)
return c.cipher.Encrypt(plain)
}
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
// (no prefix) are returned unchanged.
func (c *CryptoService) Decrypt(value string) string {
if c == nil || value == "" || c.aead == nil {
return value
}
if !strings.HasPrefix(value, encPrefix) {
return value
}
raw := strings.TrimPrefix(value, encPrefix)
data, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return value
}
if len(data) < c.aead.NonceSize() {
return value
}
nonce, cipherBytes := data[:c.aead.NonceSize()], data[c.aead.NonceSize():]
plain, err := c.aead.Open(nil, nonce, cipherBytes, nil)
if err != nil {
return value
}
return string(plain)
return c.cipher.Decrypt(value)
}
// IsEncrypted returns true if value carries the encrypted prefix.
func (c *CryptoService) IsEncrypted(value string) bool {
return strings.HasPrefix(value, encPrefix)
return c.cipher.IsEncrypted(value)
}
// MaskAPIKey returns "abcd****wxyz" so the key can be displayed in the
+9 -36
View File
@@ -49,12 +49,6 @@ const (
// dandanplay protocol (search/episodes + comment/{episodeId}) may be used.
const DanmakuDefaultSource = "https://api.dandanplay.net"
const (
minPlayerPlaybackRate = 0.5
maxPlayerPlaybackRate = 3.0
defaultPlayerPlaybackRate = 1.0
)
// danmakuOfficialBase is where identification (/api/v2/match) and the
// comment/search fallback always go, regardless of the configured source.
// A package var (not a const) so tests can point it at a local server.
@@ -69,8 +63,6 @@ type DanmakuRenderConfig struct {
FontSize string `json:"font_size"`
Area string `json:"area"`
Volume float64 `json:"volume"`
// PlaybackRate 是当前用户的播放倍速偏好(按用户存储)。
PlaybackRate float64 `json:"playback_rate"`
// Vr360GuideSeen 表示当前用户是否已经看过 VR 全景播放的首次操作说明。
Vr360GuideSeen bool `json:"vr360_guide_seen"`
// MergeSources 是当前用户的弹幕合并偏好(按用户存储)。
@@ -99,7 +91,6 @@ type DanmakuSettingsPatch struct {
Area *float64 `json:"area"`
MergeSources *bool `json:"merge_sources"`
Volume *float64 `json:"volume"`
PlaybackRate *float64 `json:"playback_rate"`
// Vr360GuideSeen 标记 VR 全景播放的首次操作说明是否已经看过。
Vr360GuideSeen *bool `json:"vr360_guide_seen"`
}
@@ -358,11 +349,10 @@ func cloneDanmakuAnimeList(in []DanmakuAnime) []DanmakuAnime {
// Config reads danmaku settings from the runtime settings table.
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
cfg := DanmakuRenderConfig{
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
PlaybackRate: defaultPlayerPlaybackRate,
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
}
if s == nil || s.repo == nil || s.repo.Setting == nil {
return cfg
@@ -414,12 +404,11 @@ func (s *DanmakuService) findUser(ctx context.Context, userID string) (*model.Us
func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if user == nil {
return DanmakuRenderConfig{
Enabled: true,
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
PlaybackRate: defaultPlayerPlaybackRate,
Enabled: true,
Opacity: "1",
FontSize: "24",
Area: "1",
Volume: 1,
}
}
opacity := user.DanmakuOpacity
@@ -438,10 +427,6 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
if volume < 0 || volume > 1 {
volume = 1
}
playbackRate := user.PlayerPlaybackRate
if playbackRate < minPlayerPlaybackRate || playbackRate > maxPlayerPlaybackRate {
playbackRate = defaultPlayerPlaybackRate
}
return DanmakuRenderConfig{
Enabled: user.DanmakuEnabled,
Source: strings.TrimSpace(user.DanmakuSource),
@@ -450,7 +435,6 @@ func danmakuConfigFromUser(user *model.User) DanmakuRenderConfig {
FontSize: strconv.Itoa(fontSize),
Area: strconv.FormatFloat(area, 'f', -1, 64),
Volume: volume,
PlaybackRate: playbackRate,
Vr360GuideSeen: user.PlayerVr360GuideSeen,
MergeSources: user.DanmakuMergeSources,
AppKeyConfigured: strings.TrimSpace(user.DanmakuAppKey) != "",
@@ -522,17 +506,6 @@ func (s *DanmakuService) UpdateUserSettings(ctx context.Context, userID string,
}
updates["player_volume"] = *patch.Volume
}
if patch.PlaybackRate != nil {
if *patch.PlaybackRate < minPlayerPlaybackRate || *patch.PlaybackRate > maxPlayerPlaybackRate {
return DanmakuRenderConfig{}, fmt.Errorf(
"%w: playback_rate must be between %.2f and %.2f",
ErrInvalidDanmakuSettings,
minPlayerPlaybackRate,
maxPlayerPlaybackRate,
)
}
updates["player_playback_rate"] = *patch.PlaybackRate
}
if patch.Vr360GuideSeen != nil {
updates["player_vr360_guide_seen"] = *patch.Vr360GuideSeen
}
-2
View File
@@ -422,7 +422,6 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
user.DanmakuFontSize = 30
user.DanmakuArea = 0.7
user.PlayerVolume = 0.42
user.PlayerPlaybackRate = 1.25
require.NoError(t, svc.repo.User.Create(ctx, &user))
seedDanmakuMedia(t, svc, "per-user-media", "测试动画", "", 0)
@@ -431,7 +430,6 @@ func TestDanmakuFetchUsesPerUserSourceAndCredentials(t *testing.T) {
require.Contains(t, res.Raw, "弹幕A")
require.Equal(t, srv.URL(), res.Source)
require.Equal(t, 0.42, res.Volume)
require.Equal(t, 1.25, res.PlaybackRate)
require.Equal(t, "0.65", res.Opacity)
require.Equal(t, "30", res.FontSize)
require.Equal(t, srv.lastHeaders.Get("X-AppId"), "user-app-id")
+143 -1
View File
@@ -6,6 +6,8 @@ import (
"encoding/hex"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
)
// Emby 发现类接口:NextUp / Similar / Genres。
@@ -21,7 +23,8 @@ const (
)
// NextUp 返回「每部在看的剧的下一集」,即 Emby 客户端首页「接下来播放」的数据源。
func (e *EmbyService) NextUp(ctx context.Context, userID string, limit int) (map[string]any, error) {
// seriesID 非空时只返回该剧的下一集(剧集详情页「继续播放」);空则返回全站列表。
func (e *EmbyService) NextUp(ctx context.Context, userID, seriesID string, limit int) (map[string]any, error) {
if limit <= 0 {
limit = embyNextUpDefaultLimit
}
@@ -31,11 +34,16 @@ func (e *EmbyService) NextUp(ctx context.Context, userID string, limit int) (map
if strings.TrimSpace(userID) == "" {
return emptyItemsEnvelope(0), nil
}
seriesID = strings.TrimSpace(seriesID)
discovery := e.discoveryService()
if discovery == nil {
return emptyItemsEnvelope(0), nil
}
if seriesID != "" {
return e.nextUpForSeries(ctx, userID, seriesID, limit)
}
rows, err := discovery.NextUpCandidates(ctx, userID, limit, e.mediaVisibility(ctx, userID))
if err != nil {
return nil, err
@@ -52,6 +60,140 @@ func (e *EmbyService) NextUp(ctx context.Context, userID string, limit int) (map
}, nil
}
// nextUpForSeries 只解析指定剧的下一集。远程挂载剧集按本机播放历史 + 远程
// 分集列表计算,避免把其它本地剧的 NextUp 塞进详情页继续播放按钮。
func (e *EmbyService) nextUpForSeries(ctx context.Context, userID, seriesID string, limit int) (map[string]any, error) {
if IsEmbyRemoteID(seriesID) {
return e.nextUpForRemoteSeries(ctx, userID, seriesID, limit)
}
discovery := e.discoveryService()
if discovery == nil {
return emptyItemsEnvelope(0), nil
}
// 多取候选再按 SeriesId 精确过滤,避免「全站 TopN」把目标剧挤掉。
scanLimit := embyNextUpMaxLimit
if limit > scanLimit {
scanLimit = limit
}
rows, err := discovery.NextUpCandidates(ctx, userID, scanLimit, e.mediaVisibility(ctx, userID))
if err != nil {
return nil, err
}
items, err := e.payloadsForMedia(ctx, rows, userID)
if err != nil {
return nil, err
}
filtered := make([]map[string]any, 0, 1)
for _, item := range items {
itemSeries, _ := item["SeriesId"].(string)
if itemSeries == seriesID {
filtered = append(filtered, item)
if len(filtered) >= limit {
break
}
}
}
return map[string]any{
"Items": filtered,
"TotalRecordCount": int64(len(filtered)),
}, nil
}
// nextUpForRemoteSeries 用 MeBox 本地播放历史在远程剧的分集里找「下一集」。
// 不透传远程账号的 NextUp,避免多用户共用挂载账号时串进度。
func (e *EmbyService) nextUpForRemoteSeries(ctx context.Context, userID, seriesID string, limit int) (map[string]any, error) {
if e == nil || e.remote == nil || strings.TrimSpace(userID) == "" {
return emptyItemsEnvelope(0), nil
}
if limit <= 0 {
limit = 1
}
mountID, remoteSeriesID, ok := DecodeEmbyRemoteID(seriesID)
if !ok {
return emptyItemsEnvelope(0), nil
}
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
return emptyItemsEnvelope(0), nil
}
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return emptyItemsEnvelope(0), nil
}
prefix := EmbyRemoteIDPrefix + mountID + "~"
var hist []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND position_ms > 0 AND media_id LIKE ?", userID, prefix+"%").
Order("watched_at desc").
Limit(nextUpHistoryScanLimit).
Find(&hist).Error; err != nil {
return nil, err
}
if len(hist) == 0 {
return emptyItemsEnvelope(0), nil
}
episodes, err := e.remote.RemoteEpisodes(ctx, mount, acct, remoteSeriesID)
if err != nil || len(episodes) == 0 {
return emptyItemsEnvelope(0), nil
}
epByID := make(map[string]*model.Media, len(episodes))
for i := range episodes {
epByID[episodes[i].ID] = &episodes[i]
}
var current *model.Media
currentCompleted := false
for i := range hist {
if m := epByID[hist[i].MediaID]; m != nil {
current = m
currentCompleted = hist[i].Completed
break
}
}
if current == nil {
return emptyItemsEnvelope(0), nil
}
completed := map[string]bool{}
epIDs := make([]string, 0, len(episodes))
for i := range episodes {
epIDs = append(epIDs, episodes[i].ID)
}
var done []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND completed = ? AND media_id IN ?", userID, true, epIDs).
Find(&done).Error; err == nil {
for _, h := range done {
completed[h.MediaID] = true
}
}
next, ok := pickNextEpisode(episodes, current, currentCompleted, completed)
if !ok {
return emptyItemsEnvelope(0), nil
}
_, remoteEpID, ok := DecodeEmbyRemoteID(next.ID)
if !ok {
return emptyItemsEnvelope(0), nil
}
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteEpID)
if err != nil || item == nil {
return emptyItemsEnvelope(0), nil
}
if err := e.mergeRemoteUserData(ctx, userID, item); err != nil {
return nil, err
}
items := []map[string]any{item}
if limit < len(items) {
items = items[:limit]
}
return map[string]any{
"Items": items,
"TotalRecordCount": int64(len(items)),
}, nil
}
// SimilarItems 返回与指定条目相似的本地媒体。
//
// 找不到条目(或该条目对当前用户不可见)时返回空列表而不是错误:客户端会在
@@ -0,0 +1,92 @@
package service
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// Emby/Jellyfin 对「不适用」的字段不下发(JSON 里没有该键),而不是下发空串。
// 小幻影视(RodelPlayer)2.2610 会把电影条目的 "SeasonId": "" 原样传进播放准备
// 逻辑并抛 ArgumentException(Parameter 'seasonId'),导致整个起播失败。
func TestEmbyMovieItemOmitsEmptySeasonAndSeriesFields(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "小姐姐", Path: `/media/小姐姐`, Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
movie := model.Media{
Base: model.Base{ID: "movie-strm"},
LibraryID: lib.ID,
Title: "SNIS-919",
Path: `/media/小姐姐/三上悠亚/SNIS-919/SNIS-919.strm`,
Container: "strm",
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
}
if err := svc.repo.DB.Create(&movie).Error; err != nil {
t.Fatalf("create media: %v", err)
}
item := svc.itemPayload(t.Context(), &movie, false, 0, false)
if item["Type"] != "Movie" {
t.Fatalf("Type = %#v, want Movie", item["Type"])
}
for _, key := range []string{"SeasonId", "SeriesId", "SeasonName", "SeriesName"} {
if value, ok := item[key]; ok {
t.Fatalf("%s must be omitted for a movie, got %#v", key, value)
}
}
}
// 剧集的季/剧归属是真实存在的,不能被一起删掉。
func TestEmbyEpisodeItemKeepsSeasonAndSeriesFields(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
episode := model.Media{
Base: model.Base{ID: "ep-1"},
LibraryID: lib.ID,
Title: "命运石之门",
Path: `/media/tv/命运石之门/Season 01/命运石之门 - S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
if err := svc.repo.DB.Create(&episode).Error; err != nil {
t.Fatalf("create media: %v", err)
}
item := svc.itemPayload(t.Context(), &episode, false, 0, false)
if item["Type"] != "Episode" {
t.Fatalf("Type = %#v, want Episode", item["Type"])
}
for _, key := range []string{"SeasonId", "SeriesId", "SeasonName", "SeriesName"} {
value, _ := item[key].(string)
if value == "" {
t.Fatalf("%s = %#v, want a non-empty value for an episode", key, item[key])
}
}
}
func TestDropEmptyStringFields(t *testing.T) {
item := map[string]any{
"SeasonId": "",
"SeriesId": " ",
"SeriesName": "命运石之门",
"IndexNumber": 0,
}
dropEmptyStringFields(item, "SeasonId", "SeriesId", "SeriesName")
if _, ok := item["SeasonId"]; ok {
t.Fatalf("empty SeasonId must be removed")
}
if _, ok := item["SeriesId"]; ok {
t.Fatalf("whitespace-only SeriesId must be removed")
}
if item["SeriesName"] != "命运石之门" {
t.Fatalf("non-empty SeriesName must be kept, got %#v", item["SeriesName"])
}
if item["IndexNumber"] != 0 {
t.Fatalf("non-string fields must be left alone, got %#v", item["IndexNumber"])
}
}
+72 -5
View File
@@ -299,8 +299,14 @@ func embyLatestSeriesRowLimit(limit int) int {
}
// ResumeItems 列出有未完成播放进度的媒体。
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
// parentID 非空时收窄到该库 / 该剧(含虚拟 msgo-series-* ID)。
func (e *EmbyService) ResumeItems(ctx context.Context, userID, parentID string, limit, startIndex int) (map[string]any, error) {
return e.resumableItems(ctx, ItemsParams{
UserID: userID,
ParentID: strings.TrimSpace(parentID),
Limit: limit,
StartIndex: startIndex,
})
}
// favoriteItems returns favourited media for Emby clients, including mounted
@@ -434,6 +440,30 @@ func favoriteMatchesParent(ctx context.Context, e *EmbyService, parentID, mediaI
return wantMountID != "" && gotMountID == wantMountID
}
// resumeMatchesParent 判断续播条目是否属于 ParentId / SeriesId 作用域。
// 本地剧集的 series_id 常为空,实际对外 ID 是 msgo-series-* 虚拟 ID,必须用
// seriesIDForMedia 对齐,否则按剧收窄永远匹配不上。
func resumeMatchesParent(ctx context.Context, e *EmbyService, parentID, libraryID, seriesID string, m *model.Media) bool {
if parentID == "" {
return true
}
if libraryID == parentID || seriesID == parentID {
return true
}
if m != nil && e.seriesIDForMedia(ctx, m) == parentID {
return true
}
if m != nil && e.seasonIDForMedia(ctx, m) == parentID {
return true
}
for _, id := range e.mergedLibraryIDs(ctx, parentID) {
if id == libraryID {
return true
}
}
return false
}
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
if p.Limit <= 0 || p.Limit > 100 {
@@ -500,7 +530,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
localTotal, remoteTotal := 0, 0
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
if p.ParentID != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID {
if p.ParentID != "" && !resumeMatchesParent(ctx, e, p.ParentID, m.LibraryID, m.SeriesID, m) {
continue
}
localTotal++
@@ -652,9 +682,21 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
seasonID = e.seasonIDForMedia(ctx, m)
parentID = seasonID
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
if episodeTitle != "" {
// 半集(S01E11.5)在 Emby 协议里只能占用整数 IndexNumber 11,标题里
// 不再带上小数就会和真正的第 11 集完全分不清,所以显式补上集号。
halfLabel := ""
if m.EpisodeFraction > 0 && m.EpisodeNum > 0 {
halfLabel = fmt.Sprintf("第 %s 集", FormatEpisodeNumber(m.EpisodeNum, m.EpisodeFraction))
}
switch {
case episodeTitle != "":
name = episodeTitle
} else if m.EpisodeNum > 0 {
if halfLabel != "" && !strings.HasPrefix(episodeTitle, halfLabel) {
name = halfLabel + " · " + episodeTitle
}
case halfLabel != "":
name = halfLabel
case m.EpisodeNum > 0:
name = fmt.Sprintf("第 %d 集", m.EpisodeNum)
}
}
@@ -745,9 +787,34 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
if includePeople {
item["People"] = e.resolveMediaPeople(ctx, m)
}
// Emby/Jellyfin 对「不适用」的字段是直接不下发(JSON 里没有该键),而不是下发
// 空串或占位值。电影等非剧集条目没有季/剧归属,小幻影视(RodelPlayer)2.2610
// 会把 "SeasonId": "" 原样传进播放准备逻辑并抛 ArgumentException
// (Parameter 'seasonId'),起播直接失败;"SeasonName": "特别篇" 也会让客户端
// 把电影当成特别篇展示。剧集则保留真实的季/剧信息,只清掉空值。
seasonKeys := []string{"SeasonId", "SeriesId", "SeasonName", "SeriesName"}
if itemType == "Episode" {
dropEmptyStringFields(item, seasonKeys...)
} else {
for _, key := range seasonKeys {
delete(item, key)
}
}
return item
}
// dropEmptyStringFields 删除值为空串(或全空白)的字符串字段。
//
// 用于对齐 Emby/Jellyfin 的序列化行为:这些字段在没有值时是「不存在」,
// 而不是空串。客户端普遍把空串当合法值使用,收到空串会当成非法参数。
func dropEmptyStringFields(item map[string]any, keys ...string) {
for _, key := range keys {
if value, ok := item[key].(string); ok && strings.TrimSpace(value) == "" {
delete(item, key)
}
}
}
func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any {
if m == nil {
return []map[string]any{}
+7
View File
@@ -153,8 +153,15 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
if rows[i].EpisodeNum != rows[j].EpisodeNum {
return rows[i].EpisodeNum < rows[j].EpisodeNum
}
if rows[i].EpisodeFraction != rows[j].EpisodeFraction {
return rows[i].EpisodeFraction < rows[j].EpisodeFraction
}
return rows[i].CreatedAt.Before(rows[j].CreatedAt)
})
// 先折叠同集的多个版本再统计总数与分页,与 payloadsForMedia 内部保持同一步骤。
// 若按未折叠的行数报 TotalRecordCount(例如 S01E11 与 S01E11.5 两行折叠成一条),
// 客户端会认为还有一条没取到,反复请求下一页 —— 分集列表就会一直加载不出来。
rows = e.collapseMediaVersionRows(ctx, rows)
total := len(rows)
items, err := e.payloadsForMedia(ctx, pageSlice(rows, p.StartIndex, p.Limit), p.UserID)
if err != nil {
+10 -6
View File
@@ -33,7 +33,7 @@ func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media)
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id IN ?", libraryIDs).
Where("season_num = ? AND episode_num = ?", m.SeasonNum, m.EpisodeNum)
Where("season_num = ? AND episode_num = ? AND episode_fraction = ?", m.SeasonNum, m.EpisodeNum, m.EpisodeFraction)
if m.TMDbID > 0 {
q = q.Where("tm_db_id = ?", m.TMDbID)
} else if m.BangumiID > 0 {
@@ -110,11 +110,15 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
}
kind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
fraction := m.EpisodeFraction
if kind != "" && kind != mediaSpecialTheatrical && episode <= 0 {
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
season, episode = parsedSeason, parsedEpisode
if parsedSeason, parsedEpisode, parsedFraction := ParseEpisodeParts(m.Path); parsedEpisode > 0 {
season, episode, fraction = parsedSeason, parsedEpisode, parsedFraction
}
}
// 集号后缀让 S01E11.5 与 S01E11 属于不同版本组;没有小数时后缀为空,
// 键与历史完全一致。
fractionSuffix := episodeFractionSuffix(fraction)
kindKey := ""
if kind != "" {
kindKey = "|kind:" + kind
@@ -126,10 +130,10 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
return libraryGroup + "|special-item:" + kind + "|id:" + m.ID
}
if m.TMDbID > 0 {
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s", libraryGroup, m.TMDbID, season, episode, kindKey)
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d%s%s", libraryGroup, m.TMDbID, season, episode, fractionSuffix, kindKey)
}
if m.BangumiID > 0 {
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s", libraryGroup, m.BangumiID, season, episode, kindKey)
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d%s%s", libraryGroup, m.BangumiID, season, episode, fractionSuffix, kindKey)
}
title := strings.ToLower(strings.TrimSpace(m.Title))
if title == "" {
@@ -138,7 +142,7 @@ func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) strin
if title == "" {
return ""
}
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s", libraryGroup, title, m.Year, season, episode, kindKey)
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d%s%s", libraryGroup, title, m.Year, season, episode, fractionSuffix, kindKey)
}
func preferMediaVersion(candidate, current model.Media) bool {
+94 -5
View File
@@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
@@ -237,6 +238,88 @@ func (r *EmbyRemoteService) ConfiguredRemoteHosts(ctx context.Context) []string
return hosts
}
// findAccountByHost 返回某条线路主机名匹配 host 的启用远程 Emby 账号(无则 nil)。
func (r *EmbyRemoteService) findAccountByHost(ctx context.Context, host string) *model.StrmAccount {
host = normalizeHostKey(host)
if host == "" || r == nil || r.repo == nil || r.repo.StrmAccount == nil {
return nil
}
accounts, err := r.ListAccounts(ctx)
if err != nil {
return nil
}
for i := range accounts {
lines, _, err := r.LinesOf(&accounts[i])
if err != nil {
continue
}
for _, line := range lines {
u, err := url.Parse(line.URL)
if err != nil {
continue
}
if normalizeHostKey(u.Hostname()) == host {
return &accounts[i]
}
}
}
return nil
}
// normalizeHostKey 去掉端口并小写化,便于主机名字符串比较。
func normalizeHostKey(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return ""
}
if h, _, err := net.SplitHostPort(host); err == nil {
return strings.ToLower(strings.TrimSpace(h))
}
return host
}
// RemoteEmbyImageTokenForHost 返回图片代理回源某个远程 Emby 主机所需的当前
// api_key。host 不属于任何已配置账号时返回 ok=false。
func (r *EmbyRemoteService) RemoteEmbyImageTokenForHost(ctx context.Context, host string) (string, bool) {
if r == nil || r.repo == nil {
return "", false
}
acct := r.findAccountByHost(ctx, host)
if acct == nil {
return "", false
}
cfg, err := r.configOf(acct)
if err != nil || strings.TrimSpace(cfg.Token) == "" {
return "", false
}
return cfg.Token, true
}
// RefreshRemoteEmbyImageTokenForHost 强制用用户名/密码重新登录拥有该主机的账号
// 并持久化新 token,供图片代理在上游 401 时自愈。仅配置了 api_key、没有登录
// 凭据的账号无法自动刷新,返回错误由调用方按原失败处理。
func (r *EmbyRemoteService) RefreshRemoteEmbyImageTokenForHost(ctx context.Context, host string) (string, error) {
if r == nil || r.repo == nil {
return "", errors.New("远程 Emby 服务不可用")
}
acct := r.findAccountByHost(ctx, host)
if acct == nil {
return "", fmt.Errorf("未找到主机 %s 对应的远程 Emby 账号", host)
}
cfg, err := r.configOf(acct)
if err != nil {
return "", err
}
if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" {
return "", errors.New("远程 Emby 账号未配置用户名/密码,无法自动刷新 api_key")
}
cfg.Token = "" // 强制走登录流程,忽略已失效的 api_key
if err := r.ensureToken(ctx, acct, cfg); err != nil {
return "", err
}
return cfg.Token, nil
}
// AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。
func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount {
if strings.TrimSpace(id) == "" {
@@ -699,17 +782,23 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
return err
}
if status == http.StatusUnauthorized && attempt == 0 {
// 401:只清当前线路的内存 token 并立即重认证;不在此时删除
// DB 里的 api_key——①外层还会按线路故障转移(其他线路可能
// 存有自己的 token);②纯 api_key 账号删除后无法再认证,一次
// 线路误报就会把账号“砖化”。重认证成功后 persistToken 会用
// 新 token 覆盖 api_key。
// 401:只清当前线路的内存 token 并立即重认证;不先删 DB 里的
// api_key——纯 api_key 账号删除后无法再认证,一次线路误报就会
// 把账号“砖化”。重认证成功后把新 token 写回:既让重试用上正确
// 凭据,也避免每个后续请求都重新登录一次。
previous := cfg.Token
cfg.Token = ""
if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil {
return fmt.Errorf("认证重试失败: %w", err)
}
if q != nil {
q.Set("api_key", cfg.Token)
}
master.Token = cfg.Token
master.RemoteUserID = cfg.RemoteUserID
if cfg.Token != "" && cfg.Token != previous {
_ = r.persistToken(ctx, acct, cfg)
}
continue
}
if status >= 300 {
@@ -0,0 +1,100 @@
package service
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestRemoteEmbyImageTokenForHostUnknownHost(t *testing.T) {
svc, _, _ := newImageTagTestService(t)
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), "nope.example"); ok || token != "" {
t.Fatalf("got (%q,%v), want no token for a host not owned by any account", token, ok)
}
}
func TestRemoteEmbyImageTokenForHostMatchesConfiguredLine(t *testing.T) {
svc, _, _ := newImageTagTestService(t)
token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), "emby.test")
if !ok || token != "fake-token" {
t.Fatalf("got (%q,%v), want the account token for the configured line host", token, ok)
}
}
func TestRefreshRemoteEmbyImageTokenForHostRequiresCredentials(t *testing.T) {
svc, _, _ := newImageTagTestService(t)
// The account carries only an api_key — there is nothing to re-authenticate with.
if _, err := svc.RefreshRemoteEmbyImageTokenForHost(t.Context(), "emby.test"); err == nil {
t.Fatal("expected an error when the account has no username/password to re-authenticate with")
}
}
// TestRefreshRemoteEmbyImageTokenForHostRelogins 覆盖「token 被撤销后自动恢复」:
// 用用户名/密码重新登录拿到新 token,并持久化,后续请求不再重复登录。
func TestRefreshRemoteEmbyImageTokenForHostRelogins(t *testing.T) {
const fresh = "fresh-token"
logins := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/emby/Users/AuthenticateByName" {
http.NotFound(w, r)
return
}
logins++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"AccessToken":"` + fresh + `","User":{"Id":"remote-user"}}`))
}))
defer upstream.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-refresh"},
Name: "refresh-emby",
Provider: model.StrmProviderEmbyRemote,
Enabled: true,
Config: `{"url":"` + upstream.URL + `","api_key":"stale-token","username":"u","password":"p"}`,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
u, err := url.Parse(upstream.URL)
if err != nil {
t.Fatal(err)
}
host := u.Hostname()
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), host); !ok || token != "stale-token" {
t.Fatalf("token before refresh = (%q,%v), want the stale stored token", token, ok)
}
token, err := svc.RefreshRemoteEmbyImageTokenForHost(t.Context(), host)
if err != nil {
t.Fatalf("refresh: %v", err)
}
if token != fresh {
t.Fatalf("refreshed token = %q, want %q", token, fresh)
}
if logins != 1 {
t.Fatalf("logins = %d, want 1", logins)
}
// 新 token 必须落库,否则每个请求都要重新登录一次。
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), host); !ok || token != fresh {
t.Fatalf("token after refresh = (%q,%v), want the persisted %q", token, ok, fresh)
}
if logins != 1 {
t.Fatalf("logins = %d, want 1 after the refresh was persisted", logins)
}
}
+240
View File
@@ -0,0 +1,240 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 回归:挂载的远程 Emby 剧集里,只看了几秒就退出的那一集必须仍是 NextUp 的结果。
// 客户端(Yamby 等)剧集详情页的「继续播放」直接取 NextUp 第一条,跳集会播错集。
func TestMountedRemoteNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
episode := func(id string, index int) map[string]any {
return map[string]any{
"Id": id,
"Name": "第" + strconv.Itoa(index) + "集",
"Type": "Episode",
"SeriesId": "series-100",
"ParentIndexNumber": 1,
"IndexNumber": index,
"RunTimeTicks": 14400640000,
}
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/series-100":
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Name": "剧一", "Type": "Series"})
case "/emby/Users/uid-1/Items/ep-2":
_ = json.NewEncoder(w).Encode(episode("ep-2", 2))
case "/emby/Users/uid-1/Items":
q := r.URL.Query()
if q.Get("IncludeItemTypes") != "Episode" || q.Get("ParentId") != "series-100" {
w.WriteHeader(http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 3,
"Items": []map[string]any{
episode("ep-1", 1),
episode("ep-2", 2),
episode("ep-3", 3),
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.PlaybackHistory{}, &model.User{})
repos := repository.New(db)
cfg := &config.Config{}
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
svc := NewEmbyService(cfg, zap.NewNop(), repos).SetEmbyRemote(remote)
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: acct.ID,
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
user := &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "x",
Role: "user",
Tier: "free",
IsActive: true,
}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatalf("create user: %v", err)
}
// 第 2 集只播了 3.5 秒就退出:有进度、未标记看完。
if err := repos.DB.Create(&model.PlaybackHistory{
UserID: user.ID,
MediaID: EncodeEmbyRemoteID(mount.ID, "ep-2"),
PositionMs: 3582,
DurationMs: 1440064,
WatchedAt: time.Now(),
Completed: false,
}).Error; err != nil {
t.Fatalf("create history: %v", err)
}
envelope, err := svc.NextUp(t.Context(), user.ID, EncodeEmbyRemoteID(mount.ID, "series-100"), 1)
if err != nil {
t.Fatalf("NextUp: %v", err)
}
items, _ := envelope["Items"].([]map[string]any)
if len(items) != 1 {
t.Fatalf("items = %d, want 1 (%#v)", len(items), envelope)
}
wantID := EncodeEmbyRemoteID(mount.ID, "ep-2")
if id, _ := items[0]["Id"].(string); id != wantID {
t.Fatalf("Id = %q, want %q (未看完的那一集不能被跳过)", id, wantID)
}
userData, _ := items[0]["UserData"].(map[string]any)
if ticks, _ := userData["PlaybackPositionTicks"].(int64); ticks != 35820000 {
t.Fatalf("PlaybackPositionTicks = %#v, want 35820000 (详情页要能续播到 3.5 秒)", userData["PlaybackPositionTicks"])
}
}
// 远程剧集已看完当前一集时,NextUp 仍要指向下一集。
func TestMountedRemoteNextUpAfterCompletedEpisode(t *testing.T) {
episode := func(id string, index int) map[string]any {
return map[string]any{
"Id": id,
"Name": "第" + strconv.Itoa(index) + "集",
"Type": "Episode",
"SeriesId": "series-100",
"ParentIndexNumber": 1,
"IndexNumber": index,
"RunTimeTicks": 14400640000,
}
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/emby/Users/uid-1/Items/series-100":
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Name": "剧一", "Type": "Series"})
case "/emby/Users/uid-1/Items/ep-3":
_ = json.NewEncoder(w).Encode(episode("ep-3", 3))
case "/emby/Users/uid-1/Items":
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 3,
"Items": []map[string]any{
episode("ep-1", 1),
episode("ep-2", 2),
episode("ep-3", 3),
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.PlaybackHistory{}, &model.User{})
repos := repository.New(db)
cfg := &config.Config{}
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
svc := NewEmbyService(cfg, zap.NewNop(), repos).SetEmbyRemote(remote)
rawConfig, _ := json.Marshal(map[string]string{
"url": server.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-1"},
Name: "远程 Emby",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-1"},
AccountID: acct.ID,
RemoteViewID: "view-1",
RemoteViewName: "新番连载",
CollectionType: "tvshows",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
user := &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "x",
Role: "user",
Tier: "free",
IsActive: true,
}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatalf("create user: %v", err)
}
// 第 2 集已整集看完。
if err := repos.DB.Create(&model.PlaybackHistory{
UserID: user.ID,
MediaID: EncodeEmbyRemoteID(mount.ID, "ep-2"),
PositionMs: 1440064,
DurationMs: 1440064,
WatchedAt: time.Now(),
Completed: true,
}).Error; err != nil {
t.Fatalf("create history: %v", err)
}
envelope, err := svc.NextUp(context.Background(), user.ID, EncodeEmbyRemoteID(mount.ID, "series-100"), 1)
if err != nil {
t.Fatalf("NextUp: %v", err)
}
items, _ := envelope["Items"].([]map[string]any)
if len(items) != 1 {
t.Fatalf("items = %d, want 1 (%#v)", len(items), envelope)
}
if id, _ := items[0]["Id"].(string); id != EncodeEmbyRemoteID(mount.ID, "ep-3") {
t.Fatalf("Id = %q, want ep-3", id)
}
}
+39 -36
View File
@@ -55,7 +55,7 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
// 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。
q = q.Where("series_id IS NULL OR series_id = ''")
}
if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
if err := q.Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return embySeriesGroup{}, false, err
}
for _, group := range e.seriesGroupsFromMedia(ctx, rows) {
@@ -68,20 +68,20 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
if series, err := e.repo.Series.FindByID(ctx, id); err != nil {
return embySeriesGroup{}, false, err
} else if series != nil {
return embySeriesGroup{
ID: series.ID,
LibraryID: series.LibraryID,
Name: series.Title,
PosterURL: series.PosterURL,
BackdropURL: series.BackdropURL,
Overview: series.Overview,
Rating: series.Rating,
Year: series.Year,
TMDbID: series.TMDbID,
BangumiID: series.BangumiID,
CreatedAt: series.CreatedAt,
DateLastMediaAdded: series.CreatedAt,
}, true, nil
return embySeriesGroup{
ID: series.ID,
LibraryID: series.LibraryID,
Name: series.Title,
PosterURL: series.PosterURL,
BackdropURL: series.BackdropURL,
Overview: series.Overview,
Rating: series.Rating,
Year: series.Year,
TMDbID: series.TMDbID,
BangumiID: series.BangumiID,
CreatedAt: series.CreatedAt,
DateLastMediaAdded: series.CreatedAt,
}, true, nil
}
}
return embySeriesGroup{}, false, nil
@@ -108,7 +108,7 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
@@ -167,7 +167,7 @@ func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, us
Where("series_id = ? AND (season_num > 0 OR episode_num > 0)", seriesID)
rq = e.applyUserMediaVisibility(ctx, rq, userID)
if err := rq.
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
@@ -194,26 +194,26 @@ func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Me
seriesID := e.seriesIDForMedia(ctx, &row)
group, ok := byID[seriesID]
if !ok {
group = &embySeriesGroup{
ID: seriesID,
LibraryID: row.LibraryID,
Name: e.seriesNameForMedia(ctx, &row),
Year: row.Year,
ReleaseDate: row.ReleaseDate,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
CreatedAt: row.CreatedAt,
DateLastMediaAdded: row.CreatedAt,
}
byID[seriesID] = group
order = append(order, seriesID)
}
if row.CreatedAt.Before(group.CreatedAt) || group.CreatedAt.IsZero() {
group.CreatedAt = row.CreatedAt
}
if row.CreatedAt.After(group.DateLastMediaAdded) {
group.DateLastMediaAdded = row.CreatedAt
group = &embySeriesGroup{
ID: seriesID,
LibraryID: row.LibraryID,
Name: e.seriesNameForMedia(ctx, &row),
Year: row.Year,
ReleaseDate: row.ReleaseDate,
TMDbID: row.TMDbID,
BangumiID: row.BangumiID,
CreatedAt: row.CreatedAt,
DateLastMediaAdded: row.CreatedAt,
}
byID[seriesID] = group
order = append(order, seriesID)
}
if row.CreatedAt.Before(group.CreatedAt) || group.CreatedAt.IsZero() {
group.CreatedAt = row.CreatedAt
}
if row.CreatedAt.After(group.DateLastMediaAdded) {
group.DateLastMediaAdded = row.CreatedAt
}
if strings.TrimSpace(row.ReleaseDate) != "" && mediaReleaseSortTime(row).After(embySeriesReleaseSortTime(*group)) {
group.ReleaseDate = row.ReleaseDate
if row.Year > 0 {
@@ -249,6 +249,9 @@ func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Me
if group.Episodes[i].EpisodeNum != group.Episodes[j].EpisodeNum {
return group.Episodes[i].EpisodeNum < group.Episodes[j].EpisodeNum
}
if group.Episodes[i].EpisodeFraction != group.Episodes[j].EpisodeFraction {
return group.Episodes[i].EpisodeFraction < group.Episodes[j].EpisodeFraction
}
return group.Episodes[i].CreatedAt.Before(group.Episodes[j].CreatedAt)
})
groups = append(groups, group)
+78 -3
View File
@@ -28,9 +28,13 @@ import (
)
var (
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
// patSEnEFraction 捕获 S01E11.5 这类「半集」写法的小数位:只在 SxxEyy 后
// 紧跟一个点加一位数字时成立;解析时还要确认该数字后面不是另一个数字,
// 否则 S01E11.1080p 的分辨率会被误读成小数集号。
patSEnEFraction = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\.(\d)`)
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
// patNxE 匹配 1x02 这类季集写法的捕获组,同时用于从标题里剔除季集残留
// (ReplaceAllString),因此本身不带边界守卫。解析时改用 patNxEGuarded,
// 避免 "1920x1080" 被从中间匹配出 "20x108" 而误判成 S20E108。
@@ -123,6 +127,62 @@ func ParseEpisode(path string) (season, episode int) {
return 0, 0
}
// ParseEpisodeParts 在 ParseEpisode 的基础上返回集号的小数部分:
// S01E11.5 → (1, 11, 0.5)。小数部分用来把「半集」和同季的整集区分开
// (折叠/身份键、排序、网页端显示集号都带上它),解析不出来时为 0。
func ParseEpisodeParts(path string) (season, episode int, fraction float64) {
name := mediaSidecarBase(path)
if name == "" {
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if m := patSEnE.FindStringSubmatch(name); len(m) == 3 {
return mustAtoi(m[1]), mustAtoi(m[2]), episodeFractionFromName(name)
}
season, episode = ParseEpisode(path)
return season, episode, 0
}
// episodeFractionFromName 从 "S01E11.5" 这类名字里取出小数部分(0.5)。
// 只在 SxxEyy 后紧跟「点 + 一位数字」、且该数字之后不是数字时才成立,
// 否则 S01E11.1080p / S01E05.10bit 会被读成小数集号。
func episodeFractionFromName(name string) float64 {
lower := strings.ToLower(name)
m := patSEnEFraction.FindStringSubmatchIndex(lower)
if len(m) != 8 {
return 0
}
digitsStart, digitsEnd := m[6], m[7]
if digitsStart < 0 || digitsEnd <= digitsStart {
return 0
}
if digitsEnd < len(lower) && lower[digitsEnd] >= '0' && lower[digitsEnd] <= '9' {
return 0
}
value, err := strconv.ParseFloat("0."+lower[digitsStart:digitsEnd], 64)
if err != nil || value <= 0 || value >= 1 {
return 0
}
return value
}
// episodeFractionSuffix 把小数部分渲染成身份/显示后缀:0.5 → ".5",无小数 → ""。
// 折叠键用它拼接,所以没有小数的集(绝大多数)产生的键与历史完全一致。
func episodeFractionSuffix(fraction float64) string {
if fraction <= 0 || fraction >= 1 {
return ""
}
formatted := strconv.FormatFloat(fraction, 'f', -1, 64)
if !strings.HasPrefix(formatted, "0.") {
return ""
}
return formatted[1:]
}
// FormatEpisodeNumber 把整数集号与小数部分拼成显示集号:11 → "11",11.5 → "11.5"。
func FormatEpisodeNumber(episode int, fraction float64) string {
return strconv.Itoa(episode) + episodeFractionSuffix(fraction)
}
// resolutionEpisodeArtifact returns the bogus (season, episode) pair the legacy
// `(\d{1,2})x(\d{1,3})` pattern would extract from a WxH pixel-dimension token
// in path — e.g. 1920x1080 -> (20, 108), 3840x2160 -> (40, 216). Reports ok=false
@@ -215,6 +275,21 @@ func onlineEpisodeIdentityFromPath(path string) (season, episode int) {
return season, episode
}
// onlineEpisodeFractionFromPath 返回按在线身份规则得到的集号小数部分:
// S01E11.5 → 0.5。只有在线身份未被重映射(SxxE00 特别篇那种改写)时才给出,
// 避免把重映射后的特别篇又带上原季的小数。
func onlineEpisodeFractionFromPath(path string) float64 {
season, episode, fraction := ParseEpisodeParts(path)
if fraction <= 0 || episode <= 0 {
return 0
}
onlineSeason, onlineEpisode := onlineEpisodeIdentityFromPath(path)
if onlineSeason != season || onlineEpisode != episode {
return 0
}
return fraction
}
type episodeRef struct {
Season int
Episode int
+57
View File
@@ -152,3 +152,60 @@ func TestDropResolutionArtifactEpisodeIdentity(t *testing.T) {
t.Fatalf("legitimate S20E108 identity was cleared: %+v", legit)
}
}
// S01E11.5 这类「半集」要保留小数部分,才能和真正的第 11 集区分开;
// 同时不能把 S01E11.1080p / S01E05.10bit 这类分辨率、位深读成小数集号。
func TestParseEpisodePartsReadsHalfEpisodes(t *testing.T) {
cases := []struct {
name string
path string
wantSeason int
wantEpisode int
wantFraction float64
}{
{"half episode in anime folder", `动漫/三月的狮子/3月的狮子 S01E11.5.mkv.strm`, 1, 11, 0.5},
{"half episode bare", `三月的狮子 S01E07.5.mkv`, 1, 7, 0.5},
{"plain episode has no fraction", `三月的狮子 S01E11.mkv`, 1, 11, 0},
{"resolution is not a fraction", `Show S01E11.1080p.WEB-DL.mkv`, 1, 11, 0},
{"bit depth is not a fraction", `Show S01E05.10bit.mkv`, 1, 5, 0},
{"special naming keeps integral episode", `三月的狮子 S00E11.mkv`, 0, 11, 0},
{"no episode marker", `movie.mkv`, 0, 0, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
season, episode, fraction := ParseEpisodeParts(tc.path)
if season != tc.wantSeason || episode != tc.wantEpisode || fraction != tc.wantFraction {
t.Fatalf("ParseEpisodeParts(%q) = (%d, %d, %v), want (%d, %d, %v)",
tc.path, season, episode, fraction, tc.wantSeason, tc.wantEpisode, tc.wantFraction)
}
// ParseEpisode 必须保持原行为(只返回整数季集号)。
plainSeason, plainEpisode := ParseEpisode(tc.path)
if plainSeason != tc.wantSeason || plainEpisode != tc.wantEpisode {
t.Fatalf("ParseEpisode(%q) = (%d, %d), want (%d, %d)",
tc.path, plainSeason, plainEpisode, tc.wantSeason, tc.wantEpisode)
}
})
}
}
func TestOnlineEpisodeFractionFromPath(t *testing.T) {
if got := onlineEpisodeFractionFromPath(`动漫/三月的狮子/3月的狮子 S01E11.5.mkv.strm`); got != 0.5 {
t.Fatalf("half episode fraction = %v, want 0.5", got)
}
if got := onlineEpisodeFractionFromPath(`三月的狮子 S01E11.mkv`); got != 0 {
t.Fatalf("plain episode fraction = %v, want 0", got)
}
// S01E00 会被重映射成特别篇 S00E01,此时不能再带上原季的小数规则。
if got := onlineEpisodeFractionFromPath(`三月的狮子 S01E00.mkv`); got != 0 {
t.Fatalf("remapped special fraction = %v, want 0", got)
}
}
func TestFormatEpisodeNumber(t *testing.T) {
if got := FormatEpisodeNumber(11, 0.5); got != "11.5" {
t.Fatalf("FormatEpisodeNumber(11, 0.5) = %q, want 11.5", got)
}
if got := FormatEpisodeNumber(11, 0); got != "11" {
t.Fatalf("FormatEpisodeNumber(11, 0) = %q, want 11", got)
}
}
+8
View File
@@ -66,6 +66,14 @@ var ErrPathOutOfBounds = errors.New("path is outside the allowed roots")
// ErrRootMutation protects configured roots such as /media and /downloads.
var ErrRootMutation = errors.New("refusing to mutate an allowed root")
// ResolvePath validates that path sits inside one of the allowed storage roots
// and returns its absolute form. Callers use it to accept a server-side path
// from the UI (file pickers) without re-implementing the boundary check.
func (s *FileManagerService) ResolvePath(path string) (string, error) {
abs, _, err := s.requireAllowedPath(path, false)
return abs, err
}
// List enumerates a directory under one of the allowed roots, returning up to
// maxEntries items sorted by (dir-first, path). Recursive listing is capped by
// maxEntries to avoid accidentally walking huge NAS trees from the UI.
+31
View File
@@ -153,6 +153,37 @@ func TestFileManagerRefusesRootMutation(t *testing.T) {
}
}
// TestFileManagerResolvePath 阅读模块的服务器选书/选目录入口靠它做边界校验:
// 允许根目录内放行,越界一律拒绝。
func TestFileManagerResolvePath(t *testing.T) {
root := t.TempDir()
svc := newFileManagerTestService(t, root)
inside := filepath.Join(root, "books", "小说.txt")
if err := os.MkdirAll(filepath.Dir(inside), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(inside, []byte("正文"), 0o644); err != nil {
t.Fatal(err)
}
got, err := svc.ResolvePath(inside)
if err != nil {
t.Fatalf("允许根目录内的路径应放行: %v", err)
}
if got != inside {
t.Fatalf("ResolvePath = %q,期望 %q", got, inside)
}
outside := filepath.Join(t.TempDir(), "越界.txt")
if err := os.WriteFile(outside, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := svc.ResolvePath(outside); !errors.Is(err, ErrPathOutOfBounds) {
t.Fatalf("越界路径 err = %v, want ErrPathOutOfBounds", err)
}
}
func hardlinksUnsupported(t *testing.T, root string) bool {
t.Helper()
src := filepath.Join(root, "hardlink-probe-src")
+11
View File
@@ -13,6 +13,7 @@
package service
import (
"context"
"errors"
"net"
"net/http"
@@ -65,6 +66,16 @@ type ImageProxy struct {
allowedHostsMu sync.Mutex
allowedHostsCache map[string]bool
allowedHostsAt time.Time
// remoteEmbyTokenFn / refreshRemoteEmbyTokenFn let the proxy carry a fresh
// credential for configured remote-Emby mounts. Image URLs are minted from
// whatever token the account had when the payload was built, so a URL can
// outlive its api_key; these hooks re-supply the current one and force a
// re-login when the mount rejects it.
remoteEmbyTokenFn func(ctx context.Context, host string) (string, bool)
refreshRemoteEmbyTokenFn func(ctx context.Context, host string) (string, error)
remoteEmbyTokenMu sync.Mutex
remoteEmbyTokenCache map[string]remoteEmbyImageToken
}
const (
+42 -9
View File
@@ -14,6 +14,10 @@ import (
var errImageProxyRequestSetup = errors.New("image proxy request setup failed")
var errImageProxyNonImageContent = errors.New("upstream returned non-image content")
// errImageProxyUnauthorized 表示上游以 401/403 拒绝了回源凭据。它与普通失败
// 分开,是为了让调用方对挂载 Emby 做一次重认证再重试,而不是直接下发占位图。
var errImageProxyUnauthorized = errors.New("upstream rejected image credentials")
// prefetchCardResizeOptions 对应前端 ARTWORK.posterCard(见 web/src/api/client.ts):
// 卡片是海报墙最常请求的档位,刮削阶段预生成它能让首个列表请求直接命中缓存。
// 若前端调整该预设,这里只是白生成一份用不到的档位(约几十 KB),不影响正确性。
@@ -183,16 +187,27 @@ func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) {
}
func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
var lastErr error
for _, candidate := range p.remoteImageFetchClients(host) {
result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath)
if err == nil {
return result, nil
result, lastErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath)
if lastErr == nil {
return result, nil
}
if errors.Is(lastErr, errImageProxyRequestSetup) {
return remoteImageFetchResult{}, lastErr
}
// 已挂载的远程 Emby 认 X-Emby-Token 请求头,但轮换前生成的图片 URL 里还留着
// 旧 api_key。上游拒绝时重新登录拥有该主机的账号一次再重试,而不是一直下发
// 占位图、等人工去改账号配置。
if errors.Is(lastErr, errImageProxyUnauthorized) {
token, refreshErr := p.refreshRemoteEmbyTokenForHost(ctx, host)
if refreshErr != nil {
logImageFetchError(p.log, "imageproxy: remote emby re-auth failed", host, "reauth", refreshErr)
} else if token != "" {
retried, retryErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath)
if retryErr == nil {
return retried, nil
}
lastErr = retryErr
}
if errors.Is(err, errImageProxyRequestSetup) {
return remoteImageFetchResult{}, err
}
lastErr = err
}
if p.canUseExternalImageFallback() && isDoubanImageHost(host) {
data, ctype, _, err := fetchRemoteImageWithCurl(ctx, raw, host)
@@ -210,6 +225,24 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca
return remoteImageFetchResult{}, redactSensitiveError(lastErr)
}
// fetchRemoteImageAcrossClients 按直连/代理顺序依次尝试,返回首个成功结果。
// 请求构造失败立即中止(重试无意义);401/403 会继续尝试另一个客户端,并把
// errImageProxyUnauthorized 作为最终错误交给调用方去刷新挂载 token。
func (p *ImageProxy) fetchRemoteImageAcrossClients(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
var lastErr error
for _, candidate := range p.remoteImageFetchClients(host) {
result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath)
if err == nil {
return result, nil
}
if errors.Is(err, errImageProxyRequestSetup) {
return remoteImageFetchResult{}, err
}
lastErr = err
}
return remoteImageFetchResult{}, lastErr
}
// fetchAndCacheRemoteImageShared coalesces concurrent requests for the same
// upstream image. A poster can appear in the hero, a shelf and the detail page
// at the same time; without this guard every resize variant may fetch the same
+102
View File
@@ -0,0 +1,102 @@
package service
import (
"context"
"net/http"
"strings"
"time"
)
// remoteEmbyImageTokenTTL 是 host → api_key 的缓存时长。海报墙首屏一口气请求
// 几十张图,逐个回读数据库不值得;token 轮换时 401 重认证会立即覆盖这份缓存。
const remoteEmbyImageTokenTTL = 60 * time.Second
type remoteEmbyImageToken struct {
token string
at time.Time
}
// SetRemoteEmbyAuthProvider 注入「按主机名取当前远程 Emby api_key」的回调。
// 图片代理回源已挂载的远程 Emby 时用它替换 URL 里可能已过期的凭据。
func (p *ImageProxy) SetRemoteEmbyAuthProvider(fn func(ctx context.Context, host string) (string, bool)) {
if p == nil {
return
}
p.remoteEmbyTokenFn = fn
}
// SetRemoteEmbyAuthRefresher 注入「强制重新登录并返回新 api_key」的回调。上游以
// 401/403 拒绝旧 token 时调用一次,再重试拉图,从而不需要人工改账号配置。
func (p *ImageProxy) SetRemoteEmbyAuthRefresher(fn func(ctx context.Context, host string) (string, error)) {
if p == nil {
return
}
p.refreshRemoteEmbyTokenFn = fn
}
// remoteEmbyTokenForHost 返回挂载 Emby 的当前 token,短期缓存避免每张图都查库。
// 「不是挂载主机」的结果同样缓存,否则普通图床(TMDb/Douban 等)的每张海报都会
// 白白查一次账号表。
func (p *ImageProxy) remoteEmbyTokenForHost(ctx context.Context, host string) string {
if p == nil || p.remoteEmbyTokenFn == nil || strings.TrimSpace(host) == "" {
return ""
}
p.remoteEmbyTokenMu.Lock()
if cached, ok := p.remoteEmbyTokenCache[host]; ok && time.Since(cached.at) < remoteEmbyImageTokenTTL {
p.remoteEmbyTokenMu.Unlock()
return cached.token
}
p.remoteEmbyTokenMu.Unlock()
token, ok := p.remoteEmbyTokenFn(ctx, host)
if !ok {
token = ""
}
p.cacheRemoteEmbyToken(host, token)
return token
}
func (p *ImageProxy) cacheRemoteEmbyToken(host, token string) {
if p == nil || strings.TrimSpace(host) == "" {
return
}
p.remoteEmbyTokenMu.Lock()
if p.remoteEmbyTokenCache == nil {
p.remoteEmbyTokenCache = make(map[string]remoteEmbyImageToken, 8)
}
p.remoteEmbyTokenCache[host] = remoteEmbyImageToken{token: token, at: time.Now()}
p.remoteEmbyTokenMu.Unlock()
}
// refreshRemoteEmbyTokenForHost 强制重认证一次并刷新缓存。没有配置刷新器、或
// 账号无法自动刷新(如仅填了 api_key)时返回空串,调用方按原错误处理。
func (p *ImageProxy) refreshRemoteEmbyTokenForHost(ctx context.Context, host string) (string, error) {
if p == nil || p.refreshRemoteEmbyTokenFn == nil || strings.TrimSpace(host) == "" {
return "", nil
}
token, err := p.refreshRemoteEmbyTokenFn(ctx, host)
if err != nil {
return "", err
}
if token != "" {
p.cacheRemoteEmbyToken(host, token)
}
return token, nil
}
// applyRemoteEmbyAuth 用账号当前 token 覆盖回源请求上的凭据。已配置的远程 Emby
// 以 X-Emby-Token 请求头为准,所以图片 URL 里带的是轮换前的旧 api_key 也不会再
// 被采纳;同时把查询参数里的 api_key 一并改写,避免旧值干扰。
func (p *ImageProxy) applyRemoteEmbyAuth(ctx context.Context, req *http.Request, host string) {
if p == nil || p.remoteEmbyTokenFn == nil || req == nil || req.URL == nil {
return
}
token := p.remoteEmbyTokenForHost(ctx, host)
if token == "" {
return
}
req.Header.Set("X-Emby-Token", token)
q := req.URL.Query()
q.Set("api_key", token)
req.URL.RawQuery = q.Encode()
}
@@ -0,0 +1,126 @@
package service
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
)
// TestImageProxyInjectsRemoteEmbyToken 覆盖封面空白问题:图片 URL 里签发时的
// api_key 已经失效,但账号当前 token 有效。代理回源必须用当前 token 覆盖旧值,
// 否则挂载 Emby 的封面会一直 401、下发占位图。
func TestImageProxyInjectsRemoteEmbyToken(t *testing.T) {
const current = "current-token"
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Emby-Token") != current {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
u, err := url.Parse(upstream.URL)
if err != nil {
t.Fatal(err)
}
proxy := newUpstreamImageProxy(t, u.Host)
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
return current, true
})
raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=stale-token"
rec := httptest.NewRecorder()
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
t.Fatal(err)
}
if !bytes.Equal(rec.Body.Bytes(), testJPEG) {
t.Fatalf("body = %x, want the upstream image served with the current token", rec.Body.Bytes())
}
}
// TestImageProxyRefreshesRemoteEmbyTokenOn401 覆盖 token 在运行中被撤销的场景:
// 账号当前 token 也被上游拒绝时,代理应重新登录一次并重试,且只刷新一次。
func TestImageProxyRefreshesRemoteEmbyTokenOn401(t *testing.T) {
const (
stale = "stale-token"
fresh = "fresh-token"
)
var mu sync.Mutex
refreshes := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Emby-Token") != fresh {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
u, err := url.Parse(upstream.URL)
if err != nil {
t.Fatal(err)
}
proxy := newUpstreamImageProxy(t, u.Host)
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
return stale, true
})
proxy.SetRemoteEmbyAuthRefresher(func(context.Context, string) (string, error) {
mu.Lock()
refreshes++
mu.Unlock()
return fresh, nil
})
raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=" + stale
rec := httptest.NewRecorder()
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
t.Fatal(err)
}
if !bytes.Equal(rec.Body.Bytes(), testJPEG) {
t.Fatalf("body = %x, want the upstream image served after re-auth", rec.Body.Bytes())
}
mu.Lock()
got := refreshes
mu.Unlock()
if got != 1 {
t.Fatalf("refreshes = %d, want exactly 1 re-auth", got)
}
}
// TestImageProxyLeavesNonEmbyHostsUntouched 确认新的凭据注入只作用于已配置的
// 远程 Emby 主机:普通图床(这里用 provider 返回 ok=false 模拟)不受影响。
func TestImageProxyDoesNotInjectForUnknownHost(t *testing.T) {
var gotHeader string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeader = r.Header.Get("X-Emby-Token")
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(testJPEG)
}))
defer upstream.Close()
u, err := url.Parse(upstream.URL)
if err != nil {
t.Fatal(err)
}
proxy := newUpstreamImageProxy(t, u.Host)
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
return "", false
})
raw := upstream.URL + "/img/cover.jpg"
rec := httptest.NewRecorder()
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
t.Fatal(err)
}
if gotHeader != "" {
t.Fatalf("X-Emby-Token = %q, want no credential header for a non-Emby host", gotHeader)
}
}
@@ -71,6 +71,8 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
return remoteImageFetchResult{}, errImageProxyRequestSetup
}
applyRemoteImageHeaders(req, host, raw)
// 已挂载的远程 Emby:用账号当前 token 覆盖旧凭据,返回头形式对端优先采纳。
p.applyRemoteEmbyAuth(ctx, req, host)
resp, err := candidate.client.Do(req)
if err != nil {
@@ -80,6 +82,9 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
defer resp.Body.Close()
if resp.StatusCode >= 400 {
p.log.Warn("imageproxy: upstream returned non-OK", zap.String("host", host), zap.String("client", candidate.name), zap.String("status", resp.Status))
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return remoteImageFetchResult{}, errImageProxyUnauthorized
}
return remoteImageFetchResult{}, errors.New("upstream returned " + resp.Status)
}
if err := p.streamImageToCache(cachePath, failPath, resp.Body); err != nil {
+1 -1
View File
@@ -53,7 +53,7 @@ func LookupLocalAvailability(ctx context.Context, repo *repository.Container, ti
var rows []model.Media
if err := repo.DB.WithContext(ctx).
Where("title LIKE ? OR original_name LIKE ? OR path LIKE ?", like, like, like).
Order("season_num asc, episode_num asc, created_at desc").
Order("season_num asc, episode_num asc, episode_fraction asc, created_at desc").
Limit(2000).
Find(&rows).Error; err != nil {
return out
+5 -1
View File
@@ -68,7 +68,11 @@ func applyLocalEpisodeMetadata(m *model.Media, local *LocalMetadata) {
if local.EpisodeTitle != "" {
m.EpisodeTitle = local.EpisodeTitle
}
if local.SeasonNum > 0 || local.EpisodeNum > 0 {
// tvshow.nfo 里常带 scraper 写的 <season>-1</season> / <episode>-1</episode>
// 哨兵(表示「整剧级、不适用」)。负季号不是有效季号:一旦让它覆盖,该集会被
// 写成 -1,网页端按 seasonLabel(-1) 显示成「剧场版」。因此负值一律忽略,
// 保留文件名解析出来的季号(S00Exx 会被解析成第 0 季 = 特别篇)。
if local.SeasonNum >= 0 && (local.SeasonNum > 0 || local.EpisodeNum > 0) {
m.SeasonNum = local.SeasonNum
}
if local.EpisodeNum > 0 {
+47
View File
@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
func TestReadLocalMetadataDropsResolutionArtifactEpisode(t *testing.T) {
@@ -436,3 +438,48 @@ func TestReadLocalMetadataFindsLegacyImgPoster(t *testing.T) {
t.Fatalf("PosterURL = %q, want legacy .img poster %q", got.PosterURL, poster)
}
}
// tvshow.nfo 里 scraper 写的 <season>-1</season>(整剧级"不适用"哨兵)不能覆盖
// 文件名解析出的季号:一旦覆盖,该集会被写成 -1,网页端按 seasonLabel(-1) 显示成
// 「剧场版」,Emby 端也只能落进特别篇。S00Exx 这类特别篇命名应保留第 0 季。
func TestApplyLocalEpisodeMetadataIgnoresNegativeSeasonSentinel(t *testing.T) {
cases := []struct {
name string
parsed model.Media
local LocalMetadata
wantSeason int
wantEpisode int
}{
{
name: "show level sentinel keeps parsed season",
parsed: model.Media{SeasonNum: 2, EpisodeNum: 5},
local: LocalMetadata{SeasonNum: -1, EpisodeNum: 5, HasNFO: true},
wantSeason: 2,
wantEpisode: 5,
},
{
name: "special naming keeps season zero",
parsed: model.Media{SeasonNum: 0, EpisodeNum: 11},
local: LocalMetadata{SeasonNum: -1, EpisodeNum: 11, HasNFO: true},
wantSeason: 0,
wantEpisode: 11,
},
{
name: "positive episode season still applies",
parsed: model.Media{SeasonNum: 0, EpisodeNum: 11},
local: LocalMetadata{SeasonNum: 1, EpisodeNum: 11, HasNFO: true},
wantSeason: 1,
wantEpisode: 11,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
media := tc.parsed
local := tc.local
applyLocalMetadata(&media, &local)
if media.SeasonNum != tc.wantSeason || media.EpisodeNum != tc.wantEpisode {
t.Fatalf("season/episode = %d/%d, want %d/%d", media.SeasonNum, media.EpisodeNum, tc.wantSeason, tc.wantEpisode)
}
})
}
}
+4
View File
@@ -82,10 +82,14 @@ func rebuildManualScrapeIdentity(media *model.Media, lib *model.Library, match *
switch mediaType {
case "tv", "anime", "variety":
media.SeasonNum, media.EpisodeNum = onlineEpisodeIdentityFromPath(media.Path)
// 半集(S01E11.5)的小数部分同样按路径重建,否则重新刮削会把 11.5
// 变回普通的第 11 集。
media.EpisodeFraction = onlineEpisodeFractionFromPath(media.Path)
media.EpisodeTitle = ""
case "movie", "adult":
media.SeasonNum = 0
media.EpisodeNum = 0
media.EpisodeFraction = 0
media.EpisodeTitle = ""
}
}
+22 -10
View File
@@ -212,10 +212,11 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
byID[watchedRows[i].ID] = &watchedRows[i]
}
// 按最近观看顺序归并到「剧」维度,同时记住该剧最近的已看集。
// 按最近观看顺序归并到「剧」维度,同时记住该剧最近看的那一集以及它是否看完。
type seriesState struct {
key string
current *model.Media
key string
current *model.Media
completed bool
}
states := make([]seriesState, 0, len(histories))
seen := make(map[string]bool, len(histories))
@@ -229,7 +230,7 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
continue
}
seen[key] = true
states = append(states, seriesState{key: key, current: m})
states = append(states, seriesState{key: key, current: m, completed: h.Completed})
}
if len(states) == 0 {
return nil, nil
@@ -293,7 +294,7 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
if len(out) >= limit {
break
}
next, ok := pickNextEpisode(bySeries[st.key], st.current, completed)
next, ok := pickNextEpisode(bySeries[st.key], st.current, st.completed, completed)
if !ok {
continue
}
@@ -341,16 +342,27 @@ func (s *MediaDiscoveryService) completedMediaIDs(ctx context.Context, userID st
return out
}
// pickNextEpisode 在候选集中选出严格晚于 current 的、编号最小的一集。
// 比较顺序为 (季, 集),因此跨季时自然落到下一季第一集。
func pickNextEpisode(candidates []model.Media, current *model.Media, completed map[string]bool) (model.Media, bool) {
// pickNextEpisode 选出这部剧「接下来该看的那一集」。
//
// anchor 是这部剧最近一次播放的那一集,anchorCompleted 表示那一集是否已看完:
// - 没看完(只播了几秒就退出、或中途暂停)时,接下来该看的仍是这一集本身。
// 否则详情页的「继续播放」会直接跳到下一集,用户刚看的那一集被静默跳过。
// - 已看完时,才在候选集里取严格晚于它的、编号最小的一集;比较顺序为
// (季, 集),因此跨季时自然落到下一季第一集。
func pickNextEpisode(candidates []model.Media, anchor *model.Media, anchorCompleted bool, completed map[string]bool) (model.Media, bool) {
if anchor == nil {
return model.Media{}, false
}
if !anchorCompleted {
return *anchor, true
}
var best model.Media
found := false
for _, candidate := range candidates {
if candidate.ID == current.ID || completed[candidate.ID] {
if candidate.ID == anchor.ID || completed[candidate.ID] {
continue
}
if !episodeAfter(candidate, *current) {
if !episodeAfter(candidate, *anchor) {
continue
}
if !found || episodeBefore(candidate, best) {
@@ -13,6 +13,9 @@ import (
)
// seedEpisode 插入一集,并把播放历史指向 `watched`(nil 表示没有历史)。
//
// position_ms / duration_ms 只是占位值,NextUp 只看历史行的 completed 字段:
// 未看完的那一集本身就是「接下来该看的一集」。
func seedEpisode(
t *testing.T,
repos *repository.Container,
@@ -78,7 +81,7 @@ func TestNextUpPicksNextEpisode(t *testing.T) {
libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, true) // 第 1 集已看完
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
@@ -89,6 +92,40 @@ func TestNextUpPicksNextEpisode(t *testing.T) {
}
}
// 回归:只播了几秒就退出(未看完)时,「接下来该看的一集」仍是这一集本身。
// 客户端(Yamby 等)剧集详情页的「继续播放」直接取 NextUp 第一条,跳集会播错集。
func TestNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Minute)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, nil, true)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, &watchedAt, false) // 第 2 集只看了几秒
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
if len(got) != 1 || got[0] != "S1E2" {
t.Fatalf("next up = %v, want [S1E2] (未看完的那一集不能跳过)", got)
}
}
// 未看完的是这部剧的最后一集时也要返回它,不能因为「后面没有集了」而返回空。
func TestNextUpKeepsPartiallyWatchedFinalEpisode(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Minute)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, nil, true)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, &watchedAt, false) // 最后一集未看完
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
if len(got) != 1 || got[0] != "S1E2" {
t.Fatalf("next up = %v, want [S1E2]", got)
}
}
// 电影不进 NextUp:NextUp 的语义是「下一集」,电影由 Resume 接口负责。
func TestNextUpSkipsMovies(t *testing.T) {
repos := newDiscoveryTestDB(t)
@@ -104,6 +141,10 @@ func TestNextUpSkipsMovies(t *testing.T) {
}
// 同一部剧有多条未看完历史时,只能出一条,且指向最靠后的已看集的下一集。
// 同一部剧有多条未看完历史时只能出一条,且指向最近看过的那一集。
//
// 最近那一集(S1E2)本身还没看完,所以它就是「接下来该看的一集」;
// S1E1 只是更早的中间进度,不能据此跳到 S1E3。
func TestNextUpOneEntryPerSeries(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
@@ -116,8 +157,8 @@ func TestNextUpOneEntryPerSeries(t *testing.T) {
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
if len(got) != 1 || got[0] != "S1E3" {
t.Fatalf("next up = %v, want [S1E3]", got)
if len(got) != 1 || got[0] != "S1E2" {
t.Fatalf("next up = %v, want [S1E2]", got)
}
}
@@ -127,7 +168,7 @@ func TestNextUpCrossesSeason(t *testing.T) {
libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 12, &watchedAt, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 12, &watchedAt, true) // 第 1 季最后一集已看完
seedEpisode(t, repos, libID, "series-1", "剧一", 2, 1, nil, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 2, 2, nil, false)
@@ -144,8 +185,8 @@ func TestNextUpSkipsCompletedEpisode(t *testing.T) {
libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, true) // 已看完
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, true) // 已看完,下一集是 S1E3
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, true) // 已看完
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
@@ -155,13 +196,13 @@ func TestNextUpSkipsCompletedEpisode(t *testing.T) {
}
}
// 追到最后一集时没有下一集,结果为空而不是重复返回最后一集。
// 追到最后一集且已看完时没有下一集,结果为空而不是重复返回最后一集。
func TestNextUpEmptyAtSeriesEnd(t *testing.T) {
repos := newDiscoveryTestDB(t)
libID := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, &watchedAt, false)
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, &watchedAt, true)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
if got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true}); len(got) != 0 {
@@ -176,7 +217,7 @@ func TestNextUpRespectsVisibility(t *testing.T) {
hiddenLib := seedDiscoveryLibrary(t, repos, "tv")
watchedAt := time.Now().Add(-time.Hour)
seedEpisode(t, repos, visibleLib, "series-1", "剧一", 1, 1, &watchedAt, false)
seedEpisode(t, repos, visibleLib, "series-1", "剧一", 1, 1, &watchedAt, true)
seedEpisode(t, repos, hiddenLib, "series-1", "剧一", 1, 2, nil, false)
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
+16 -1
View File
@@ -19,7 +19,10 @@ type MediaMetadataUpdate struct {
Rating *float32 `json:"rating"`
SeasonNum *int `json:"season_num"`
EpisodeNum *int `json:"episode_num"`
TMDbID *int `json:"tmdb_id"`
// EpisodeFraction 是集号的小数部分(11.5 的 0.5)。单独提供是为了让编辑
// 「集」时不会把半集写成整数集号,也不会把整集写成小数。
EpisodeFraction *float64 `json:"episode_fraction"`
TMDbID *int `json:"tmdb_id"`
BangumiID *int `json:"bangumi_id"`
DoubanID *string `json:"douban_id"`
TheTVDBID *string `json:"thetvdb_id"`
@@ -77,6 +80,9 @@ func (s *MediaService) UpdateMetadata(ctx context.Context, id string, req MediaM
if req.EpisodeNum != nil {
updates["episode_num"] = clampNonNegativeInt(*req.EpisodeNum)
}
if req.EpisodeFraction != nil {
updates["episode_fraction"] = clampEpisodeFraction(*req.EpisodeFraction)
}
if req.TMDbID != nil {
updates["tm_db_id"] = clampNonNegativeInt(*req.TMDbID)
}
@@ -141,6 +147,15 @@ func clampNonNegativeInt(value int) int {
return value
}
// clampEpisodeFraction 把集号小数部分夹到 [0,1):小于 0 或大于等于 1 都视为
// 「没有小数」,避免把非法值写进库。
func clampEpisodeFraction(value float64) float64 {
if value <= 0 || value >= 1 {
return 0
}
return value
}
func clampRating(value float32) float32 {
if value < 0 {
return 0
+15 -43
View File
@@ -18,18 +18,7 @@ import (
"github.com/truewhile/MeBox/internal/repository"
)
// 播放档案里可选的片头/片尾数据来源。
const (
// SegmentSourceAuto 优先用文件内嵌章节(对这个片源最准),没有可用章节时
// 回落到 TheIntroDB。
SegmentSourceAuto = "auto"
// SegmentSourceFFprobe 只用文件内嵌章节。
SegmentSourceFFprobe = "ffprobe"
// SegmentSourceTheIntroDB 只用社区数据库。它同时是 media_segments.source 的
// 取值——两处必须一致,所以直接复用提供方的常量。
SegmentSourceTheIntroDB = IntroDBSource
)
// 探测结果的缓存与预算策略。
const (
// mediaProbeTimeout 是一次后台探测的总预算(含把 strm 目标解析成直链)。
mediaProbeTimeout = 90 * time.Second
@@ -41,31 +30,25 @@ const (
mediaProbeErrorLimit = 200
)
// NormalizeSegmentSource 把任意输入收敛到合法取值;未知值一律按 auto 处理
// (历史档案里这一列可能还是空串)。
func NormalizeSegmentSource(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case SegmentSourceFFprobe:
return SegmentSourceFFprobe
case SegmentSourceTheIntroDB:
return SegmentSourceTheIntroDB
default:
return SegmentSourceAuto
}
}
// mediaProber 是 MediaProbeService 需要的探测能力。抽成接口是为了在测试里注入
// 桩,避免依赖真实 ffprobe 二进制。
type mediaProber interface {
ProbeFull(ctx context.Context, input ProbeInput) (*FullProbeResult, error)
}
// MediaProbeService 用 ffprobe 提取媒体的完整信息(容器、每路轨道、内嵌章节),
// 把结果落库缓存,并把章节映射成可跳过的片头/片尾区间。
// MediaProbeService 用 ffprobe 提取媒体的基础信息(容器、每路轨道、内嵌章节)
// 并落库缓存。
//
// 核心约束:一次探测要 3~4 秒(远端直链更慢,跨洋要跑三次 HTTP 事务),所以
// 只允许异步跑。播放链路永远只读缓存,拿不到就下次再来——绝不能让一次探测挡在
// 起播路径上。
// 定位是「播放时顺带补齐媒体信息」:STRM / 云盘媒体在扫描阶段拿不到时长,而
// 播放链路要用缓存里的时长来换算「延续到片尾」这类区间,详情页将来也直接读这份
// 媒体信息。
//
// 它**不参与片头/片尾判定**:章节标题绝大多数没有语义(生产库实测抽样 64 个
// 文件,命中 0 个),拿它去猜跳过点只会给出错误的位置,时间轴数据仍然只信
// TheIntroDB。
//
// 核心约束:一次探测要 2~4.5 秒(远端直链要跨洋跑几次 HTTP 事务),所以只允许
// 异步跑,播放链路永远只读缓存。
type MediaProbeService struct {
log *zap.Logger
repo *repository.Container
@@ -200,22 +183,11 @@ func (s *MediaProbeService) probeInput(ctx context.Context, m *model.Media) (Pro
return ProbeInput{}, errors.New("strm probe source unavailable")
}
// persistProbe 把一次成功的探测落库:先写片段区间,再写探测行。
//
// 顺序很重要:探测行是「已经探过」的标记,客户端靠它决定要不要继续轮询。先写
// 它会让客户端在区间还没落库时就停止等待。
// persistProbe 把一次成功的探测落库。
func (s *MediaProbeService) persistProbe(ctx context.Context, m *model.Media, result *FullProbeResult) error {
if s.repo == nil || s.repo.MediaProbe == nil || s.repo.MediaSegment == nil {
if s.repo == nil || s.repo.MediaProbe == nil {
return errors.New("media probe repository not wired")
}
rows := chaptersToSegments(result.Chapters)
for i := range rows {
rows[i].MediaID = m.ID
rows[i].SeriesID = m.SeriesID
}
if err := s.repo.MediaSegment.ReplaceForMedia(ctx, m.ID, SegmentSourceFFprobe, rows); err != nil {
return err
}
payload, err := result.PayloadJSON()
if err != nil {
return err
-72
View File
@@ -1,72 +0,0 @@
package service
import (
"regexp"
"strings"
"github.com/truewhile/MeBox/internal/model"
)
// chapterMinMs 是章节被判为「可跳过区间」的最短长度。太短的章节没有可跳过的
// 内容,多半是章节切分噪声。
const chapterMinMs = 2000
// chapterKindPatterns 是章节标题 → 片段类型的映射表。
//
// 只认「有语义的」标题。大量发布组的章节叫 Chapter 01/02…,那种一律不猜:
// auto 档会优先采用章节数据,猜错会让跳过按钮指到错误的位置,比拿不到数据更糟。
// 匹配不到就返回空串,让 auto 回落到 TheIntroDB。
//
// 西文关键词要求词边界(前后不能是字母),否则 "Options" 会被当成片头(op)、
// "Weekend" 会被当成片尾(end)。中日文关键词按子串匹配(CJK 不是 [a-z],所以
// 同一条边界规则对它们天然成立)。
var chapterKindPatterns = []struct {
kind string
pattern *regexp.Regexp
}{
{model.SegmentKindIntro, regexp.MustCompile(`(?i)(^|[^a-z])(intro|opening|op|ncop)([^a-z]|$)|片头|オープニング`)},
{model.SegmentKindRecap, regexp.MustCompile(`(?i)(^|[^a-z])(recap|previously)([^a-z]|$)|前情|回顾|あらすじ`)},
{model.SegmentKindCredits, regexp.MustCompile(`(?i)(^|[^a-z])(credits|ending|end|outro|nced|ed)([^a-z]|$)|片尾|エンディング`)},
{model.SegmentKindPreview, regexp.MustCompile(`(?i)(^|[^a-z])(preview|next\s+episode|next\s+time)([^a-z]|$)|预告|次回|予告`)},
}
// chapterTitleKind 返回章节标题对应的片段类型;识别不出来时返回空串。
func chapterTitleKind(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return ""
}
for _, entry := range chapterKindPatterns {
if entry.pattern.MatchString(title) {
return entry.kind
}
}
return ""
}
// chaptersToSegments 把内嵌章节映射成可跳过的区间。
//
// 只有标题能识别出类型时才产出区间;EndMs 为 0 的章节落成 0(= 延续到片尾),
// 与提供方契约一致,由客户端按媒体总时长补齐。
func chaptersToSegments(chapters []ProbeChapter) []model.MediaSegment {
rows := make([]model.MediaSegment, 0, 4)
for _, chapter := range chapters {
kind := chapterTitleKind(chapter.Title)
if kind == "" || chapter.StartMs < 0 {
continue
}
if chapter.EndMs > 0 && chapter.EndMs-chapter.StartMs < chapterMinMs {
continue
}
rows = append(rows, model.MediaSegment{
Kind: kind,
StartMs: chapter.StartMs,
EndMs: chapter.EndMs,
Source: SegmentSourceFFprobe,
})
}
return rows
}
@@ -1,111 +0,0 @@
package service
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 章节标题只有「有语义」时才认。西文关键词要词边界,否则 Options / Weekend
// 这类标题会被误判成片头/片尾;中文日文按子串匹配。
func TestChapterTitleKind(t *testing.T) {
cases := []struct {
title string
want string
}{
{"Intro", model.SegmentKindIntro},
{"Opening", model.SegmentKindIntro},
{"Opening Title", model.SegmentKindIntro},
{"OP", model.SegmentKindIntro},
{"NCOP", model.SegmentKindIntro},
{"片头", model.SegmentKindIntro},
{"オープニング", model.SegmentKindIntro},
{"Recap", model.SegmentKindRecap},
{"Previously on", model.SegmentKindRecap},
{"前情提要", model.SegmentKindRecap},
{"回顾", model.SegmentKindRecap},
{"あらすじ", model.SegmentKindRecap},
{"End Credits", model.SegmentKindCredits},
{"Credits", model.SegmentKindCredits},
{"Ending", model.SegmentKindCredits},
{"ED", model.SegmentKindCredits},
{"Outro", model.SegmentKindCredits},
{"片尾", model.SegmentKindCredits},
{"エンディング", model.SegmentKindCredits},
{"Next Episode", model.SegmentKindPreview},
{"Next time", model.SegmentKindPreview},
{"Preview", model.SegmentKindPreview},
{"预告", model.SegmentKindPreview},
{"次回予告", model.SegmentKindPreview},
// 无语义或容易误判的标题一律不认:猜错比没有数据更糟。
{"", ""},
{" ", ""},
{"Chapter 01", ""},
{"Chapter 12", ""},
{"Options", ""},
{"Weekend", ""},
{"Main Menu", ""},
{"Menu", ""},
}
for _, tc := range cases {
if got := chapterTitleKind(tc.title); got != tc.want {
t.Errorf("chapterTitleKind(%q) = %q, want %q", tc.title, got, tc.want)
}
}
}
func TestChaptersToSegmentsOnlyKeepsSemanticChapters(t *testing.T) {
rows := chaptersToSegments([]ProbeChapter{
{Index: 0, StartMs: 0, EndMs: 95_000, Title: "Chapter 01"},
{Index: 1, StartMs: 228_664, EndMs: 246_143, Title: "Opening"},
{Index: 2, StartMs: 246_500, EndMs: 247_400, Title: "Recap"}, // 不足 chapterMinMs
{Index: 3, StartMs: 3_431_000, EndMs: 0, Title: "End Credits"},
})
if len(rows) != 2 {
t.Fatalf("rows = %#v, want 2 (Opening + End Credits)", rows)
}
if rows[0].Kind != model.SegmentKindIntro || rows[0].StartMs != 228_664 || rows[0].EndMs != 246_143 {
t.Fatalf("first row = %#v", rows[0])
}
if rows[1].Kind != model.SegmentKindCredits || rows[1].StartMs != 3_431_000 || rows[1].EndMs != 0 {
t.Fatalf("second row = %#v", rows[1])
}
for _, row := range rows {
if row.Source != SegmentSourceFFprobe {
t.Fatalf("source = %q, want %q", row.Source, SegmentSourceFFprobe)
}
}
}
func TestChaptersToSegmentsIgnoresUntitledAndNegativeChapters(t *testing.T) {
rows := chaptersToSegments([]ProbeChapter{
{Index: 0, StartMs: 0, EndMs: 60_000, Title: ""},
{Index: 1, StartMs: -1, EndMs: 60_000, Title: "Intro"},
})
if len(rows) != 0 {
t.Fatalf("rows = %#v, want none", rows)
}
}
func TestNormalizeSegmentSourceFallsBackToAuto(t *testing.T) {
cases := map[string]string{
"": SegmentSourceAuto,
" ": SegmentSourceAuto,
"auto": SegmentSourceAuto,
"AUTO": SegmentSourceAuto,
"ffprobe": SegmentSourceFFprobe,
" FFprobe ": SegmentSourceFFprobe,
"theintrodb": SegmentSourceTheIntroDB,
"TheIntroDB": SegmentSourceTheIntroDB,
"aniskip": SegmentSourceAuto, // 未知值按 auto 处理
}
for input, want := range cases {
if got := NormalizeSegmentSource(input); got != want {
t.Errorf("NormalizeSegmentSource(%q) = %q, want %q", input, got, want)
}
}
}
+5 -22
View File
@@ -110,7 +110,7 @@ func waitForCondition(t *testing.T, timeout time.Duration, cond func() bool) {
t.Fatal("condition was not met before the deadline")
}
func TestMediaProbeRunPersistsSegmentsAndProbeRow(t *testing.T) {
func TestMediaProbeRunPersistsProbeRow(t *testing.T) {
svc, repos, m := newProbeFixture(t)
prober := &stubProber{result: chapterProbeResult()}
svc.probe = prober
@@ -123,17 +123,6 @@ func TestMediaProbeRunPersistsSegmentsAndProbeRow(t *testing.T) {
t.Fatalf("probe source = %q, want the local path %q", got, m.Path)
}
rows, err := repos.MediaSegment.ListByMediaSource(t.Context(), m.ID, SegmentSourceFFprobe)
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("segments = %#v, want the 2 semantic chapters", rows)
}
if rows[0].Kind != model.SegmentKindIntro || rows[0].SeriesID != "s-1" {
t.Fatalf("segment[0] = %#v, want an intro carrying the series id", rows[0])
}
probe, err := repos.MediaProbe.Get(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
@@ -170,7 +159,7 @@ func TestMediaProbeRunPersistsSegmentsAndProbeRow(t *testing.T) {
}
}
func TestMediaProbeFailureKeepsPreviousProbeAndSegments(t *testing.T) {
func TestMediaProbeFailureKeepsPreviousSummary(t *testing.T) {
svc, repos, m := newProbeFixture(t)
prober := &stubProber{result: chapterProbeResult()}
svc.probe = prober
@@ -186,17 +175,11 @@ func TestMediaProbeFailureKeepsPreviousProbeAndSegments(t *testing.T) {
if probe == nil || probe.LastError == "" {
t.Fatal("a failed re-probe must record the error")
}
// 关键:失败只更新时间与错误信息,上一次成功的媒体信息与片段必须留着。
if probe.DurationSec != 1451 || probe.Container != "matroska,webm" {
// 关键:失败只更新时间与错误信息,上一次成功的媒体信息必须留着——
// 否则一次失败的重探会把已经拿到的时长(片尾区间换算要用)抹掉。
if probe.DurationSec != 1451 || probe.Container != "matroska,webm" || probe.Payload == "" {
t.Fatalf("a failed re-probe wiped the previous summary: %#v", probe)
}
rows, err := repos.MediaSegment.ListByMediaSource(t.Context(), m.ID, SegmentSourceFFprobe)
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("segments = %#v, want the previous 2 kept", rows)
}
}
// 探测有结论后就不再重探;失败要等冷却期过去才允许重试。
+232 -126
View File
@@ -18,9 +18,21 @@ import (
// 片段数据的缓存时长。命中过说明社区库里已有记录、数据很少变动,可以放很久;
// 未命中说明这部片还没人贡献,隔一段时间再试一次即可——负缓存是必须的,否则
// 每次播放一部没有片段数据的影片都会打一次外网。
//
// 未命中 TTL 刻意短于命中:社区库在持续补充,尤其是热门剧,24h 重试一次比
// 锁死 7 天更能跟上贡献节奏;预热任务也会优先扫最近播放过的 miss。
const (
segmentFoundTTL = 30 * 24 * time.Hour
segmentMissingTTL = 7 * 24 * time.Hour
segmentMissingTTL = 24 * time.Hour
// SegmentSourceManual / Propagated 与 IntroDBSource 并列,播放时按优先级合并。
SegmentSourceManual = "manual"
SegmentSourcePropagated = "propagated"
// segmentPrewarmDefaultLimit 是一次预热任务最多处理的媒体数,避免单次跑太久。
segmentPrewarmDefaultLimit = 200
// segmentPrewarmInterval 是预热请求之间的间隔,压低对 TheIntroDB 的 429。
segmentPrewarmInterval = time.Second
)
// SegmentView 是播放器消费的最小片段结构,避免把库内字段(source 等)暴露给前端。
@@ -44,14 +56,29 @@ type MediaSegmentService struct {
log *zap.Logger
repo *repository.Container
introdb *IntroDBService
// probe 负责从文件内嵌章节里提取片头/片尾(异步、落库)。未注入时只用
// TheIntroDB。
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用 TheIntroDB。
probe *MediaProbeService
// prewarmGap 是预热两次 IntroDB 请求之间的间隔;测试可设为 0。
prewarmGap time.Duration
now func() time.Time
}
// NewMediaSegmentService is the constructor.
func NewMediaSegmentService(log *zap.Logger, repo *repository.Container) *MediaSegmentService {
return &MediaSegmentService{log: log, repo: repo}
return &MediaSegmentService{
log: log,
repo: repo,
prewarmGap: segmentPrewarmInterval,
now: time.Now,
}
}
// SetPrewarmGap overrides the delay between prewarm fetches (tests use 0).
func (s *MediaSegmentService) SetPrewarmGap(gap time.Duration) *MediaSegmentService {
if s != nil {
s.prewarmGap = gap
}
return s
}
// SetIntroDB wires the provider. Without it the service only reads cached rows.
@@ -62,8 +89,8 @@ func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService
return s
}
// SetProbe wires the in-file chapter extractor. Without it the ffprobe source
// simply yields nothing and auto falls back to TheIntroDB.
// SetProbe wires the async media probe (duration backfill for STRM). Without it
// open-ended credits still work once duration is known from elsewhere.
func (s *MediaSegmentService) SetProbe(p *MediaProbeService) *MediaSegmentService {
if s != nil {
s.probe = p
@@ -71,138 +98,45 @@ func (s *MediaSegmentService) SetProbe(p *MediaProbeService) *MediaSegmentServic
return s
}
// SegmentsResult 是播放器一次查询的结果。
type SegmentsResult struct {
// Segments 是按当前来源选定、可以直接用来跳过的区间。
Segments []model.MediaSegment
// Pending 为 true 表示 ffprobe 提取还在后台跑:这次可能还没有章节数据,
// 客户端过几秒再拉一次就能拿到;那时如果片头还没播完,跳过按钮会自动出现。
Pending bool
}
// SegmentsForPlayback 返回播放器该用的片段,并按需触发数据补齐。
// ListForPlayback returns the segments known for a media item, refreshing from
// the provider when the cache is stale.
//
// 它绝不做阻塞起播的事:TheIntroDB 的抓取沿用原来的「以调用方 deadline 为预算」,
// ffprobe 提取则完全异步。抓取或提取失败只是少一个「跳过片头」按钮、或晚几秒
// 出现,绝不能让播放报错。
func (s *MediaSegmentService) SegmentsForPlayback(ctx context.Context, m *model.Media, source string) (SegmentsResult, error) {
// 它不做任何阻塞起播的事情——调用方是在播放已经开始之后用一次独立请求进来的,
// 抓取失败也只是少一个「跳过片头」按钮,绝不能让播放报错。
//
// 时间轴数据只来自 TheIntroDB。顺带在返回前起一次异步探测补齐媒体信息(主要是
// STRM 媒体的时长),但探测结果不参与片段判定,详见 MediaProbeService 的说明。
func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
if s == nil || s.repo == nil || m == nil || m.ID == "" {
return SegmentsResult{}, nil
return nil, nil
}
source = NormalizeSegmentSource(source)
result := SegmentsResult{}
// 只用社区库时连探测都不该触发:没必要为一次用不上的章节提取去跑 ffprobe。
var chapterRows []model.MediaSegment
needsProbe := false
if source != SegmentSourceTheIntroDB {
chapterRows, needsProbe = s.chapterSegments(ctx, m)
}
result.Pending = needsProbe
// 用 defer 保证无论走哪条分支、包括中途出错,后台提取都会排上队;同时它一定在
// 所有前台数据库读写之后才启动(见 triggerAsyncProbe 的说明)。
defer triggerAsyncProbe(s, m, needsProbe)
if source == SegmentSourceTheIntroDB {
introRows, err := s.introDBSegments(ctx, m)
if err != nil {
return result, err
}
result.Segments = introRows
return result, nil
}
// ffprobe 档、以及 auto 档下已经有可用章节的情况,都整体采用章节数据。
//
// 章节与社区库的数据刻意不合并:两边对同一集的判定会互相矛盾(同一集的片尾
// 起点能差上百秒),只能按 media 整体二选一。auto 走到这里说明章节可用,也就
// 不必再去打一次用不上的社区库。
if source == SegmentSourceFFprobe || len(chapterRows) > 0 {
result.Segments = chapterRows
return result, nil
}
// auto 且没有可用章节:回落到社区库;Pending 保留,客户端会再拉一次。
introRows, err := s.introDBSegments(ctx, m)
if err != nil {
return result, err
}
result.Segments = introRows
return result, nil
// 用 defer 保证探测一定在本次请求所有数据库读写之后才启动:后台探测自己也要
// 写库,若在本次写事务还没结束时启动,两个写事务会抢同一把锁(SQLite 下就是
// SQLITE_BUSY,实测能直接把社区库的落库打失败)。
defer s.ensureMediaProbe(ctx, m)
return s.introDBSegments(ctx, m)
}
// triggerAsyncProbe 在所有前台数据库读写都结束之后再起后台提取。
// ensureMediaProbe 在还没探过(或上次失败已过冷却期)时起一次异步探测。
//
// 顺序很关键:后台探测自己也要写库,若在本次请求的写事务还没结束时启动,两个写
// 事务会抢同一把锁(SQLite 下就是 SQLITE_BUSY)。
func triggerAsyncProbe(s *MediaSegmentService, m *model.Media, needsProbe bool) {
if !needsProbe || s == nil || s.probe == nil {
// 它只为「补齐媒体信息」服务:失败只是拿不到时长,不影响播放,也不影响片段。
func (s *MediaSegmentService) ensureMediaProbe(ctx context.Context, m *model.Media) {
if s == nil || s.probe == nil || m == nil {
return
}
cached, err := s.repo.MediaProbe.Get(ctx, m.ID)
if err != nil {
s.debug("get media probe failed", m.ID, err)
return
}
if mediaProbeSettled(cached) {
return
}
s.probe.EnsureAsync(m)
}
// ListForPlayback 供 Emby / Jellyfin 兼容接口使用:按 auto 档取数据(章节优先,
// 回落社区库)。第三方客户端不会轮询,所以这里只返回当前能拿到的部分。
func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
result, err := s.SegmentsForPlayback(ctx, m, SegmentSourceAuto)
if err != nil {
return nil, err
}
return result.Segments, nil
}
// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
cached, err := s.repo.MediaSegment.ListByMediaSource(ctx, m.ID, IntroDBSource)
if err != nil {
return nil, err
}
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource)
if err != nil {
return nil, err
}
if ledger != nil && ledgerFresh(ledger) {
return cached, nil
}
refreshed, attempted, err := s.refresh(ctx, m)
if err != nil {
// 社区库不可达或返回异常:沿用已有缓存,不影响播放。
logIntroDBFailure(s.log, 0, err)
return cached, nil
}
if !attempted {
return cached, nil
}
return refreshed, nil
}
// chapterSegments 读 ffprobe 提取出的章节区间,并报告「是否还需要等一次提取结果」。
//
// 它只读、不启动提取:调用方要等所有前台数据库读写都结束之后再起后台任务,否则
// 后台写事务会和本次请求的写事务抢同一把锁。探测失败的结果也会落库,所以不会
// 每次播放都为同一个坏源重跑。
func (s *MediaSegmentService) chapterSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool) {
if s == nil || s.probe == nil || s.repo == nil {
return nil, false
}
rows, err := s.repo.MediaSegment.ListByMediaSource(ctx, m.ID, SegmentSourceFFprobe)
if err != nil {
s.debug("list ffprobe segments failed", m.ID, err)
return nil, false
}
cached, err := s.repo.MediaProbe.Get(ctx, m.ID)
if err != nil {
s.debug("get media probe failed", m.ID, err)
return nil, false
}
if mediaProbeSettled(cached) {
return rows, false
}
// 还没探过、或失败已过冷却期:值得让客户端稍后再来一次。
return rows, true
}
// mediaProbeSettled 判断这部媒体的探测是否已经「有结论」——成功过,或者失败但还在
// 冷却期内。有结论就不必再探,客户端也不用继续轮询;失败且已过冷却期时返回
// false,让下一次播放重试。
// 冷却期内。有结论就不必再探;失败且已过冷却期时返回 false,让下一次播放重试。
func mediaProbeSettled(row *model.MediaProbe) bool {
if row == nil {
return false
@@ -213,6 +147,76 @@ func mediaProbeSettled(row *model.MediaProbe) bool {
return time.Since(row.ProbedAt) < mediaProbeFailureRetry
}
// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
// 返回值是按来源优先级合并后的结果(manual > theintrodb > propagated)。
func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource)
if err != nil {
// 调用方预算耗尽时仍读本地缓存,绝不能让播放接口报错。
if ctx.Err() != nil {
return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID)
}
return nil, err
}
if ledger == nil || !ledgerFresh(ledger) {
if _, _, err := s.refresh(ctx, m); err != nil {
// 社区库不可达或返回异常:沿用已有缓存,不影响播放;也不写负缓存。
logIntroDBFailure(s.log, 0, err)
}
}
// 合并读库只碰本地,脱离调用方 deadline,避免外网抓取耗尽预算后读缓存也失败。
return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID)
}
// mergeForPlayback 合并同一媒体上多来源片段。同 kind 只保留优先级最高的来源。
func (s *MediaSegmentService) mergeForPlayback(ctx context.Context, mediaID string) ([]model.MediaSegment, error) {
rows, err := s.repo.MediaSegment.ListByMedia(ctx, mediaID)
if err != nil {
return nil, err
}
return preferSegmentsBySource(rows), nil
}
// segmentSourcePriority 数值越大越优先。未知来源视为最低,避免挡住已知源。
func segmentSourcePriority(source string) int {
switch source {
case SegmentSourceManual:
return 3
case IntroDBSource:
return 2
case SegmentSourcePropagated:
return 1
default:
return 0
}
}
// preferSegmentsBySource 按 kind 选取最高优先级来源的全部区间。
func preferSegmentsBySource(rows []model.MediaSegment) []model.MediaSegment {
bestPri := make(map[string]int, 4)
byKind := make(map[string][]model.MediaSegment, 4)
for _, row := range rows {
pri := segmentSourcePriority(row.Source)
cur, seen := bestPri[row.Kind]
if !seen || pri > cur {
bestPri[row.Kind] = pri
byKind[row.Kind] = []model.MediaSegment{row}
continue
}
if pri == cur {
byKind[row.Kind] = append(byKind[row.Kind], row)
}
}
out := make([]model.MediaSegment, 0, len(rows))
for _, kind := range []string{
model.SegmentKindIntro, model.SegmentKindRecap,
model.SegmentKindCredits, model.SegmentKindPreview,
} {
out = append(out, byKind[kind]...)
}
return out
}
func (s *MediaSegmentService) debug(message, mediaID string, err error) {
if s == nil || s.log == nil {
return
@@ -278,9 +282,111 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
}); err != nil {
return nil, true, err
}
s.propagateIntroToSeason(fetchCtx, m, rows)
return rows, true, nil
}
// propagateIntroToSeason 把本集 IntroDB 命中的 intro 复制到同季还没有
// theintrodb intro 的兄弟集。片尾/预告不传播:各集时长与片尾位置经常不同。
func (s *MediaSegmentService) propagateIntroToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
if s == nil || s.repo == nil || m == nil || m.SeasonNum <= 0 {
return
}
intros := introSpansFrom(rows)
if len(intros) == 0 {
return
}
siblings, err := s.repo.Media.ListSeasonSiblings(ctx, m)
if err != nil {
s.debug("list season siblings failed", m.ID, err)
return
}
for i := range siblings {
sib := &siblings[i]
existing, err := s.repo.MediaSegment.ListByMediaSource(ctx, sib.ID, IntroDBSource)
if err != nil {
s.debug("list sibling introdb segments failed", sib.ID, err)
continue
}
if hasSegmentKind(existing, model.SegmentKindIntro) {
continue
}
copied := make([]model.MediaSegment, 0, len(intros))
for _, intro := range intros {
copied = append(copied, model.MediaSegment{
MediaID: sib.ID,
SeriesID: sib.SeriesID,
Kind: model.SegmentKindIntro,
StartMs: intro.StartMs,
EndMs: intro.EndMs,
Source: SegmentSourcePropagated,
})
}
if err := s.repo.MediaSegment.ReplaceForMedia(ctx, sib.ID, SegmentSourcePropagated, copied); err != nil {
s.debug("propagate intro failed", sib.ID, err)
}
}
}
func introSpansFrom(rows []model.MediaSegment) []model.MediaSegment {
out := make([]model.MediaSegment, 0, 1)
for _, row := range rows {
if row.Kind == model.SegmentKindIntro {
out = append(out, row)
}
}
return out
}
func hasSegmentKind(rows []model.MediaSegment, kind string) bool {
for _, row := range rows {
if row.Kind == kind {
return true
}
}
return false
}
// Prewarm 批量向 TheIntroDB 补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, error) {
if s == nil || s.repo == nil || s.introdb == nil {
return 0, nil
}
if limit <= 0 {
limit = segmentPrewarmDefaultLimit
}
now := time.Now()
if s.now != nil {
now = s.now()
}
candidates, err := s.repo.MediaSegment.ListPrewarmCandidates(
ctx, IntroDBSource, now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
)
if err != nil {
return 0, err
}
attempted := 0
for i := range candidates {
if err := ctx.Err(); err != nil {
return attempted, err
}
m := &candidates[i]
_, did, err := s.refresh(ctx, m)
if err != nil {
logIntroDBFailure(s.log, m.TMDbID, err)
}
if did {
attempted++
}
if i+1 < len(candidates) && s.prewarmGap > 0 {
if !waitForIntroDBRetry(ctx, s.prewarmGap) {
return attempted, ctx.Err()
}
}
}
return attempted, nil
}
// queryIDs resolves the provider query key. Movies use their own TMDb id.
//
// 剧集需要「剧集级」TMDb id 加季/集。优先取 Series.TMDbID;但有些刮削路径
@@ -0,0 +1,77 @@
package service
import (
"testing"
"time"
"go.uber.org/zap"
)
// 播放请求要顺带把媒体信息(主要是 STRM 媒体的时长)补齐,但探测不能影响片段:
// ListForPlayback 只读社区库,探测在返回前才异步排上队。
func TestListForPlaybackTriggersMediaProbe(t *testing.T) {
probeSvc, repos, m := newProbeFixture(t)
prober := &stubProber{result: chapterProbeResult()}
probeSvc.probe = prober
segments := NewMediaSegmentService(zap.NewNop(), repos).SetProbe(probeSvc)
rows, err := segments.ListForPlayback(t.Context(), m)
if err != nil {
t.Fatalf("ListForPlayback: %v", err)
}
if len(rows) != 0 {
t.Fatalf("rows = %#v, want none without a provider", rows)
}
// 探测是异步的:等它跑完并落库。
waitForCondition(t, 5*time.Second, func() bool { return prober.callCount() >= 1 })
probeRow, err := repos.MediaProbe.Get(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
}
if probeRow == nil {
t.Fatal("the playback path should have probed the media info")
}
// 已经有结论的媒体不该被反复探测:每次播放重跑一次 2~4.5 秒的远端读取太贵。
if _, err := segments.ListForPlayback(t.Context(), m); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
if got := prober.callCount(); got != 1 {
t.Fatalf("probe calls = %d, want 1 (a settled probe must not repeat)", got)
}
}
// 探测失败也要有结论:冷却期内不再重探。
func TestListForPlaybackDoesNotReprobeWithinFailureCooldown(t *testing.T) {
probeSvc, repos, m := newProbeFixture(t)
prober := &stubProber{result: chapterProbeResult()}
probeSvc.probe = prober
if err := repos.MediaProbe.MarkFailure(t.Context(), m.ID, "boom", time.Now()); err != nil {
t.Fatal(err)
}
segments := NewMediaSegmentService(zap.NewNop(), repos).SetProbe(probeSvc)
if _, err := segments.ListForPlayback(t.Context(), m); err != nil {
t.Fatal(err)
}
time.Sleep(50 * time.Millisecond)
if got := prober.callCount(); got != 0 {
t.Fatalf("probe calls = %d, want 0 inside the cooldown", got)
}
}
// 没注入探测服务时(ffprobe 不可用的精简部署)播放链路必须照常工作。
func TestListForPlaybackWithoutProbeStillWorks(t *testing.T) {
_, repos, m := newProbeFixture(t)
segments := NewMediaSegmentService(zap.NewNop(), repos)
rows, err := segments.ListForPlayback(t.Context(), m)
if err != nil {
t.Fatalf("ListForPlayback: %v", err)
}
if len(rows) != 0 {
t.Fatalf("rows = %#v, want none", rows)
}
}
@@ -1,223 +0,0 @@
package service
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// segmentSourceFixture 提供一个带社区库假服务端、可选探测桩、以及一条已有本地
// 文件的媒体行,用来验证「数据来源选择」这一层。
type segmentSourceFixture struct {
svc *MediaSegmentService
repos *repository.Container
calls *int32
prober *stubProber
media *model.Media
}
func newSegmentSourceFixture(t *testing.T, prober *stubProber) *segmentSourceFixture {
t.Helper()
repos := repository.New(newServiceTestDB(t))
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(introDBMoviePayload))
}))
t.Cleanup(server.Close)
svc := NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL).SetRetryDelay(0))
dir := t.TempDir()
path := filepath.Join(dir, "inception.mkv")
if err := os.WriteFile(path, []byte("data"), 0o600); err != nil {
t.Fatal(err)
}
m := &model.Media{Base: model.Base{ID: "mv-1"}, Title: "盗梦空间", Path: path, TMDbID: 27205}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
if prober != nil {
probeSvc := NewMediaProbeService(zap.NewNop(), repos, nil)
probeSvc.probe = prober
svc.SetProbe(probeSvc)
}
return &segmentSourceFixture{svc: svc, repos: repos, calls: &calls, prober: prober, media: m}
}
// seedChapters 模拟「已经提取过并且拿到了章节」:写入章节区间 + 探测行(探测行
// 是「已经探过」的标记,客户端靠它停止轮询)。
func (f *segmentSourceFixture) seedChapters(t *testing.T, rows ...model.MediaSegment) {
t.Helper()
seeded := make([]model.MediaSegment, 0, len(rows))
for _, row := range rows {
row.MediaID = f.media.ID
row.Source = SegmentSourceFFprobe
seeded = append(seeded, row)
}
if err := f.repos.MediaSegment.ReplaceForMedia(t.Context(), f.media.ID, SegmentSourceFFprobe, seeded); err != nil {
t.Fatal(err)
}
if err := f.repos.MediaProbe.Upsert(t.Context(), &model.MediaProbe{
MediaID: f.media.ID,
ProbedAt: time.Now(),
ChapterCount: len(seeded),
}); err != nil {
t.Fatal(err)
}
}
func chapterIntroRow() model.MediaSegment {
return model.MediaSegment{Kind: model.SegmentKindIntro, StartMs: 228_664, EndMs: 246_143}
}
// auto 档在有章节时必须整体采用章节,并且不打社区库。
func TestSegmentsForPlaybackAutoPrefersChapters(t *testing.T) {
f := newSegmentSourceFixture(t, &stubProber{result: chapterProbeResult()})
f.seedChapters(t, chapterIntroRow())
for _, source := range []string{SegmentSourceAuto, SegmentSourceFFprobe, "未知值"} {
result, err := f.svc.SegmentsForPlayback(t.Context(), f.media, source)
if err != nil {
t.Fatalf("source %q: %v", source, err)
}
if len(result.Segments) != 1 || result.Segments[0].StartMs != 228_664 {
t.Fatalf("source %q segments = %#v, want the chapter row", source, result.Segments)
}
if result.Segments[0].Source != SegmentSourceFFprobe {
t.Fatalf("source %q returned a %q row", source, result.Segments[0].Source)
}
if result.Pending {
t.Fatalf("source %q reported pending although the probe is cached", source)
}
}
if got := atomic.LoadInt32(f.calls); got != 0 {
t.Fatalf("provider calls = %d, want 0: chapters must not trigger TheIntroDB", got)
}
}
// theintrodb 档必须忽略章节,并且不触发任何探测。
func TestSegmentsForPlaybackIntroDBOnlyIgnoresChapters(t *testing.T) {
prober := &stubProber{result: chapterProbeResult()}
f := newSegmentSourceFixture(t, prober)
f.seedChapters(t, chapterIntroRow())
result, err := f.svc.SegmentsForPlayback(t.Context(), f.media, SegmentSourceTheIntroDB)
if err != nil {
t.Fatal(err)
}
if len(result.Segments) != 1 || result.Segments[0].Source != IntroDBSource {
t.Fatalf("segments = %#v, want the community row", result.Segments)
}
if result.Segments[0].StartMs != 0 || result.Segments[0].EndMs != 38_000 {
t.Fatalf("segments = %#v, want the movie intro from the provider", result.Segments)
}
if result.Pending {
t.Fatal("theintrodb-only must never report pending")
}
// 只用社区库时不该为一次用不上的章节提取去跑 3 秒 ffprobe。
if got := prober.callCount(); got != 0 {
t.Fatalf("probe calls = %d, want 0", got)
}
}
// auto 档没有章节时回落到社区库,同时告诉客户端「章节还在提取」。
func TestSegmentsForPlaybackAutoFallsBackAndReportsPending(t *testing.T) {
prober := &stubProber{result: chapterProbeResult()}
f := newSegmentSourceFixture(t, prober)
result, err := f.svc.SegmentsForPlayback(t.Context(), f.media, SegmentSourceAuto)
if err != nil {
t.Fatal(err)
}
if len(result.Segments) != 1 || result.Segments[0].Source != IntroDBSource {
t.Fatalf("segments = %#v, want the community fallback", result.Segments)
}
if !result.Pending {
t.Fatal("auto without chapters must report that a probe is running")
}
// 异步提取确实排上了队(这里只验证调度;落库由 media_probe_test.go 覆盖)。
waitForCondition(t, 5*time.Second, func() bool { return prober.callCount() >= 1 })
}
// ffprobe 档没有章节数据时返回空,但会起一次提取并报告 pending。
func TestSegmentsForPlaybackFFprobeOnlyReturnsEmptyWhilePending(t *testing.T) {
prober := &stubProber{result: chapterProbeResult()}
f := newSegmentSourceFixture(t, prober)
result, err := f.svc.SegmentsForPlayback(t.Context(), f.media, SegmentSourceFFprobe)
if err != nil {
t.Fatal(err)
}
if len(result.Segments) != 0 {
t.Fatalf("segments = %#v, want none before extraction finishes", result.Segments)
}
if !result.Pending {
t.Fatal("ffprobe-only must report pending while the extraction runs")
}
if got := atomic.LoadInt32(f.calls); got != 0 {
t.Fatalf("provider calls = %d, want 0", got)
}
}
// 提取失败也要有个结果:不能在每次播放时无限重试,客户端也不该一直轮询。
func TestSegmentsForPlaybackStopsPendingAfterFailedProbe(t *testing.T) {
prober := &stubProber{result: chapterProbeResult()}
f := newSegmentSourceFixture(t, prober)
if err := f.repos.MediaProbe.MarkFailure(t.Context(), f.media.ID, "probe exploded", time.Now()); err != nil {
t.Fatal(err)
}
result, err := f.svc.SegmentsForPlayback(t.Context(), f.media, SegmentSourceFFprobe)
if err != nil {
t.Fatal(err)
}
if result.Pending {
t.Fatal("a recent failure must stop the client from polling")
}
if got := prober.callCount(); got != 0 {
t.Fatalf("probe calls = %d, want 0 inside the failure cooldown", got)
}
}
// 没有注入探测服务时(ffprobe 不可用的精简部署)不能谎报 pending。
func TestSegmentsForPlaybackWithoutProbeNeverReportsPending(t *testing.T) {
f := newSegmentSourceFixture(t, nil)
result, err := f.svc.SegmentsForPlayback(t.Context(), f.media, SegmentSourceFFprobe)
if err != nil {
t.Fatal(err)
}
if result.Pending {
t.Fatal("pending must be false when no prober is wired")
}
if len(result.Segments) != 0 {
t.Fatalf("segments = %#v, want none", result.Segments)
}
}
// ListForPlayback 仍供 Emby / Jellyfin 兼容接口使用,按 auto 档取数。
func TestListForPlaybackUsesAutoSelection(t *testing.T) {
f := newSegmentSourceFixture(t, &stubProber{result: chapterProbeResult()})
f.seedChapters(t, chapterIntroRow())
rows, err := f.svc.ListForPlayback(t.Context(), f.media)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Source != SegmentSourceFFprobe {
t.Fatalf("rows = %#v, want the chapter row via auto", rows)
}
}
+118
View File
@@ -385,6 +385,123 @@ func TestListForPlaybackRespectsCallerDeadline(t *testing.T) {
}
}
func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing.T) {
rows := []model.MediaSegment{
{Kind: model.SegmentKindIntro, StartMs: 1, EndMs: 2, Source: SegmentSourcePropagated},
{Kind: model.SegmentKindIntro, StartMs: 10, EndMs: 20, Source: IntroDBSource},
{Kind: model.SegmentKindIntro, StartMs: 100, EndMs: 200, Source: SegmentSourceManual},
{Kind: model.SegmentKindCredits, StartMs: 1000, EndMs: 0, Source: SegmentSourcePropagated},
{Kind: model.SegmentKindCredits, StartMs: 2000, EndMs: 0, Source: IntroDBSource},
}
got := preferSegmentsBySource(rows)
if len(got) != 2 {
t.Fatalf("got %#v, want manual intro + introdb credits", got)
}
if got[0].Source != SegmentSourceManual || got[0].StartMs != 100 {
t.Fatalf("intro = %#v, want manual", got[0])
}
if got[1].Source != IntroDBSource || got[1].StartMs != 2000 {
t.Fatalf("credits = %#v, want theintrodb", got[1])
}
}
func TestListForPlaybackPropagatesIntroToSeasonSiblings(t *testing.T) {
svc, repos, _ := newSegmentServiceFixture(t, writeJSONBody(introDBTVPayload))
svc.SetPrewarmGap(0)
ctx := t.Context()
episodes := []*model.Media{
{Base: model.Base{ID: "ep-1"}, LibraryID: "lib-anime", Title: "便·当",
Path: "/anime/S01E01.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61970},
{Base: model.Base{ID: "ep-2"}, LibraryID: "lib-anime", Title: "便·当",
Path: "/anime/S01E02.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61970},
}
for _, ep := range episodes {
if err := repos.DB.Create(ep).Error; err != nil {
t.Fatal(err)
}
}
rows, err := svc.ListForPlayback(ctx, episodes[0])
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("ep1 rows = %#v, want intro+credits from provider", rows)
}
sib, err := repos.MediaSegment.ListByMediaSource(ctx, "ep-2", SegmentSourcePropagated)
if err != nil {
t.Fatal(err)
}
if len(sib) != 1 || sib[0].Kind != model.SegmentKindIntro {
t.Fatalf("sibling propagated = %#v, want intro only", sib)
}
if sib[0].StartMs != 228_664 || sib[0].EndMs != 246_143 {
t.Fatalf("propagated window = %#v", sib[0])
}
// 兄弟集播放时应直接看到传播来的 intro(即使自己还没打过 IntroDB)。
if err := repos.MediaSegment.UpsertFetch(ctx, &model.MediaSegmentFetch{
MediaID: "ep-2", Source: IntroDBSource, FetchedAt: time.Now(), Found: false,
}); err != nil {
t.Fatal(err)
}
merged, err := svc.ListForPlayback(ctx, episodes[1])
if err != nil {
t.Fatal(err)
}
if len(merged) != 1 || merged[0].Kind != model.SegmentKindIntro || merged[0].Source != SegmentSourcePropagated {
t.Fatalf("ep2 playback = %#v, want propagated intro", merged)
}
}
func TestPrewarmFetchesStaleMissesPreferringRecentPlays(t *testing.T) {
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBMoviePayload))
svc.SetPrewarmGap(0)
ctx := t.Context()
old := &model.Media{Base: model.Base{ID: "mv-old"}, Path: "/a.mkv", TMDbID: 111}
hot := &model.Media{Base: model.Base{ID: "mv-hot"}, Path: "/b.mkv", TMDbID: 27205}
for _, m := range []*model.Media{old, hot} {
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
}
// 两条都是过期 miss,热播的应优先被预热。
stale := time.Now().Add(-segmentMissingTTL - time.Hour)
for _, id := range []string{"mv-old", "mv-hot"} {
if err := repos.MediaSegment.UpsertFetch(ctx, &model.MediaSegmentFetch{
MediaID: id, Source: IntroDBSource, FetchedAt: stale, Found: false,
}); err != nil {
t.Fatal(err)
}
}
if err := repos.DB.Create(&model.PlaybackHistory{
Base: model.Base{ID: "h-1"}, UserID: "u1", MediaID: "mv-hot",
}).Error; err != nil {
t.Fatal(err)
}
n, err := svc.Prewarm(ctx, 1)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("attempted = %d, want 1", n)
}
if got := atomic.LoadInt32(calls); got != 1 {
t.Fatalf("provider calls = %d, want 1", got)
}
ledger, err := repos.MediaSegment.GetFetch(ctx, "mv-hot", IntroDBSource)
if err != nil || ledger == nil || !ledger.Found {
t.Fatalf("hot ledger = %#v err=%v, want found", ledger, err)
}
oldLedger, err := repos.MediaSegment.GetFetch(ctx, "mv-old", IntroDBSource)
if err != nil || oldLedger == nil || oldLedger.Found {
t.Fatalf("old ledger should remain a miss, got %#v err=%v", oldLedger, err)
}
}
func TestLedgerFreshUsesLongerTTLWhenDataWasFound(t *testing.T) {
now := time.Now()
found := &model.MediaSegmentFetch{FetchedAt: now.Add(-segmentMissingTTL), Found: true}
@@ -399,3 +516,4 @@ func TestLedgerFreshUsesLongerTTLWhenDataWasFound(t *testing.T) {
t.Fatal("a missing ledger must not be considered fresh")
}
}
+7 -1
View File
@@ -287,7 +287,7 @@ func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID,
return out, nil
}
// sortEpisodesForDisplay 与历史行为一致:季/集号升序,再按入库时间兜底。
// sortEpisodesForDisplay 与历史行为一致:季/集号升序(含半集的小数部分),再按入库时间兜底。
func sortEpisodesForDisplay(out []model.Media) {
sort.SliceStable(out, func(i, j int) bool {
if out[i].SeasonNum != out[j].SeasonNum {
@@ -296,6 +296,9 @@ func sortEpisodesForDisplay(out []model.Media) {
if out[i].EpisodeNum != out[j].EpisodeNum {
return out[i].EpisodeNum < out[j].EpisodeNum
}
if out[i].EpisodeFraction != out[j].EpisodeFraction {
return out[i].EpisodeFraction < out[j].EpisodeFraction
}
return out[i].CreatedAt.Before(out[j].CreatedAt)
})
}
@@ -403,6 +406,9 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
if out[i].EpisodeNum != out[j].EpisodeNum {
return out[i].EpisodeNum < out[j].EpisodeNum
}
if out[i].EpisodeFraction != out[j].EpisodeFraction {
return out[i].EpisodeFraction < out[j].EpisodeFraction
}
if out[i].Path != out[j].Path {
return out[i].Path < out[j].Path
}
+15 -8
View File
@@ -134,6 +134,9 @@ func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
if grouped[i].EpisodeNum != grouped[j].EpisodeNum {
return grouped[i].EpisodeNum < grouped[j].EpisodeNum
}
if grouped[i].EpisodeFraction != grouped[j].EpisodeFraction {
return grouped[i].EpisodeFraction < grouped[j].EpisodeFraction
}
return grouped[i].CreatedAt.Before(grouped[j].CreatedAt)
})
return grouped
@@ -192,7 +195,7 @@ func adultVouchedGroupKey(m model.Media, vouched map[string]bool) string {
return fmt.Sprintf("adult:%s:%s", libKey, code)
}
func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
if IsEmbyRemoteID(m.ID) {
return fmt.Sprintf("embyremote:%s", m.ID)
}
@@ -200,11 +203,15 @@ func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保
libKey := mediaVersionLibraryKey(m)
specialKind := mediaSpecialKind(m.Path)
season, episode := m.SeasonNum, m.EpisodeNum
fraction := m.EpisodeFraction
if specialKind != "" && specialKind != mediaSpecialTheatrical && episode <= 0 {
if parsedSeason, parsedEpisode := ParseEpisode(m.Path); parsedEpisode > 0 {
season, episode = parsedSeason, parsedEpisode
if parsedSeason, parsedEpisode, parsedFraction := ParseEpisodeParts(m.Path); parsedEpisode > 0 {
season, episode, fraction = parsedSeason, parsedEpisode, parsedFraction
}
}
// 集号后缀:S01E11.5 记成 "11.5",没有小数的集后缀为空,
// 因此绝大多数条目的版本身份键与历史完全一致。
episodeTag := fmt.Sprintf("%d:%d%s", season, episode, episodeFractionSuffix(fraction))
if season > 0 || episode > 0 {
kind := specialKind
@@ -213,13 +220,13 @@ func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保
}
switch {
case m.TMDbID > 0:
return fmt.Sprintf("episode:%s:tmdb:%d:%d:%d", kind, m.TMDbID, season, episode)
return fmt.Sprintf("episode:%s:tmdb:%d:%s", kind, m.TMDbID, episodeTag)
case m.BangumiID > 0:
return fmt.Sprintf("episode:%s:bangumi:%d:%d:%d", kind, m.BangumiID, season, episode)
return fmt.Sprintf("episode:%s:bangumi:%d:%s", kind, m.BangumiID, episodeTag)
case strings.TrimSpace(m.DoubanID) != "":
return fmt.Sprintf("episode:%s:douban:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.DoubanID)), season, episode)
return fmt.Sprintf("episode:%s:douban:%s:%s", kind, strings.ToLower(strings.TrimSpace(m.DoubanID)), episodeTag)
case strings.TrimSpace(m.TheTVDBID) != "":
return fmt.Sprintf("episode:%s:thetvdb:%s:%d:%d", kind, strings.ToLower(strings.TrimSpace(m.TheTVDBID)), season, episode)
return fmt.Sprintf("episode:%s:thetvdb:%s:%s", kind, strings.ToLower(strings.TrimSpace(m.TheTVDBID)), episodeTag)
}
title := firstNonEmpty(m.OriginalName, m.Title)
if title == "" {
@@ -234,7 +241,7 @@ func mediaVersionGroupKey(m model.Media) string { // 远程 Emby 挂载条目保
kind,
libKey,
title,
fmt.Sprintf("%d:%d", season, episode),
episodeTag,
}, "|")
}
+50
View File
@@ -737,3 +737,53 @@ func TestMediaAdultGroupCodeRequiresNSFW(t *testing.T) {
})
}
}
// S01E11 与 S01E11.5 必须折叠成两条不同记录(既不合并,也不丢集),
// 且 11.5 排在 11 之后、12 之前。这是「第一季分集加载不出来」那次的根因:
// 两条同季同集的记录被折成一条,计数与返回条目对不上。
func TestGroupEpisodeVersionsForDisplayKeepsHalfEpisodeSeparate(t *testing.T) {
rows := []model.Media{
{Base: model.Base{ID: "ep12"}, LibraryID: "anime", Title: "三月的狮子", SeasonNum: 1, EpisodeNum: 12, TMDbID: 65336, Path: "/anime/三月的狮子/3月的狮子 S01E12.mkv"},
{Base: model.Base{ID: "ep11"}, LibraryID: "anime", Title: "三月的狮子", SeasonNum: 1, EpisodeNum: 11, TMDbID: 65336, Path: "/anime/三月的狮子/3月的狮子 S01E11.mkv"},
{Base: model.Base{ID: "ep11half"}, LibraryID: "anime", Title: "三月的狮子", SeasonNum: 1, EpisodeNum: 11, EpisodeFraction: 0.5, TMDbID: 65336, Path: "/anime/三月的狮子/3月的狮子 S01E11.5.mkv"},
}
grouped := GroupEpisodeVersionsForDisplay(rows)
if len(grouped) != 3 {
t.Fatalf("grouped len = %d, want 3 (11 / 11.5 / 12): %#v", len(grouped), grouped)
}
wantOrder := []string{"ep11", "ep11half", "ep12"}
for i, want := range wantOrder {
if grouped[i].ID != want {
t.Fatalf("grouped[%d] = %q, want %q (order %#v)", i, grouped[i].ID, want, grouped)
}
}
for _, item := range grouped {
if len(item.Versions) != 0 {
t.Fatalf("half episode must not fold with the integral one: %#v", grouped)
}
}
}
// 版本身份键必须区分 11 与 11.5,同时保持没有小数时的历史键不变。
func TestMediaVersionGroupKeyDistinguishesHalfEpisode(t *testing.T) {
base := model.Media{LibraryID: "anime", Title: "三月的狮子", SeasonNum: 1, EpisodeNum: 11}
plain := base
half := base
half.EpisodeFraction = 0.5
plainKey := mediaVersionGroupKey(plain)
halfKey := mediaVersionGroupKey(half)
if plainKey == "" || halfKey == "" {
t.Fatalf("version keys must not be empty: %q %q", plainKey, halfKey)
}
if plainKey == halfKey {
t.Fatalf("11 and 11.5 must have different version keys, both = %q", plainKey)
}
if !strings.HasSuffix(halfKey, "1:11.5") {
t.Fatalf("half episode key = %q, want suffix 1:11.5", halfKey)
}
if !strings.HasSuffix(plainKey, "1:11") {
t.Fatalf("plain episode key = %q, want suffix 1:11", plainKey)
}
}
+3 -1
View File
@@ -142,7 +142,9 @@ func WriteMediaNFO(m *model.Media) (string, error) {
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
title := strings.TrimSpace(m.EpisodeTitle)
if title == "" && m.EpisodeNum > 0 {
title = fmt.Sprintf("第 %d 集", m.EpisodeNum)
// 兜底标题也要带上小数:S01E11.5 导出成「第 11.5 集」,
// 否则回写 NFO 后重新入库会把半集写成普通的第 11 集。
title = fmt.Sprintf("第 %s 集", FormatEpisodeNumber(m.EpisodeNum, m.EpisodeFraction))
}
if title == "" {
title = strings.TrimSpace(m.Title)
-2
View File
@@ -94,7 +94,6 @@ func (s *PlayProfileService) Create(ctx context.Context, in PlayProfileInput) (*
PreferredAudioLang: in.PreferredAudioLang,
AutoplayNext: in.AutoplayNext,
SkipIntro: in.SkipIntro,
SegmentSource: NormalizeSegmentSource(in.SegmentSource),
AllowedLibraryIDs: string(libsBlob),
}
if in.RequirePIN && in.PIN != "" {
@@ -154,7 +153,6 @@ func (s *PlayProfileService) updateExisting(ctx context.Context, row *model.Play
"preferred_audio_lang": in.PreferredAudioLang,
"autoplay_next": in.AutoplayNext,
"skip_intro": in.SkipIntro,
"segment_source": NormalizeSegmentSource(in.SegmentSource),
"allowed_library_ids": string(libsBlob),
}
if in.RequirePIN && in.PIN != "" {
+12 -14
View File
@@ -13,20 +13,18 @@ import (
// PlayProfileInput is the create/update payload accepted by the API.
// PIN is hashed only when non-empty so omitting it preserves the existing PIN on update.
type PlayProfileInput struct {
UserID string `json:"user_id"`
Name string `json:"name"`
IsDefault bool `json:"is_default"`
ContentRatingLimit string `json:"content_rating_limit"`
AllowAdult bool `json:"allow_adult"`
RequirePIN bool `json:"require_pin"`
PIN string `json:"pin,omitempty"`
PreferredSubtitleLang string `json:"preferred_subtitle_lang"`
PreferredAudioLang string `json:"preferred_audio_lang"`
AutoplayNext bool `json:"autoplay_next"`
SkipIntro bool `json:"skip_intro"`
// SegmentSource 是片头/片尾数据来源:auto | theintrodb | ffprobe。
SegmentSource string `json:"segment_source"`
AllowedLibraryIDs []string `json:"allowed_library_ids"`
UserID string `json:"user_id"`
Name string `json:"name"`
IsDefault bool `json:"is_default"`
ContentRatingLimit string `json:"content_rating_limit"`
AllowAdult bool `json:"allow_adult"`
RequirePIN bool `json:"require_pin"`
PIN string `json:"pin,omitempty"`
PreferredSubtitleLang string `json:"preferred_subtitle_lang"`
PreferredAudioLang string `json:"preferred_audio_lang"`
AutoplayNext bool `json:"autoplay_next"`
SkipIntro bool `json:"skip_intro"`
AllowedLibraryIDs []string `json:"allowed_library_ids"`
}
// ProfileView is the public shape for React forms.
@@ -0,0 +1,44 @@
package reader
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// TestSaveAudioConfigPersistsCredits 听书跳过片头/片尾设置要落库,且只能改自己的书。
func TestSaveAudioConfigPersistsCredits(t *testing.T) {
svc, repos := newLoginTestService(t)
ctx := t.Context()
book := &model.ReaderBook{UserID: "u1", Name: "宠魅", Type: 1}
if err := repos.Reader.CreateBook(ctx, book); err != nil {
t.Fatal(err)
}
if err := svc.SaveAudioConfig(ctx, "u1", book.ID, 30, 15); err != nil {
t.Fatalf("保存听书设置失败: %v", err)
}
got, err := svc.GetBook(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if got.OpenCredits != 30 || got.CloseCredits != 15 {
t.Fatalf("片头/片尾 = %d/%d,期望 30/15", got.OpenCredits, got.CloseCredits)
}
// 0 是合法值(不跳过),必须能写回
if err := svc.SaveAudioConfig(ctx, "u1", book.ID, 0, 0); err != nil {
t.Fatalf("清零失败: %v", err)
}
if got, _ = svc.GetBook(ctx, book.ID); got.OpenCredits != 0 || got.CloseCredits != 0 {
t.Fatalf("清零后 = %d/%d,期望 0/0", got.OpenCredits, got.CloseCredits)
}
if err := svc.SaveAudioConfig(ctx, "u1", book.ID, -1, 0); err == nil {
t.Fatal("负数应被拒绝")
}
if err := svc.SaveAudioConfig(ctx, "other", book.ID, 10, 10); err == nil {
t.Fatal("他人书架应被拒绝")
}
}

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