Compare commits

...

21 Commits

Author SHA1 Message Date
truewhile 08d521b65d Merge changes from prometheus/mebox-40a7378b 2026-11-12 22:38:54 +08:00
truewhile 5806839601 Merge changes from prometheus/mebox-40a7378b 2026-11-12 22:13:57 +08:00
truewhile d7c150ffd9 优化 2026-11-12 18:58:45 +08:00
truewhile 79943d9067 优化 2026-11-05 17:03:26 +08:00
truewhile 9ffac320c4 优化 2026-11-04 10:16:12 +08:00
truewhile 9b8ca33279 bug处理 2026-11-01 09:23:58 +08:00
truewhile 34e61a5994 优化 2026-10-31 12:48:50 +08:00
truewhile d91ded9d55 优化 2026-10-30 18:54:26 +08:00
truewhile 8458b8060a 优化 2026-10-30 10:45:59 +08:00
truewhile 623d87f12b 优化 2026-10-29 23:47:18 +08:00
truewhile d820214abc 优化 2026-10-29 22:52:39 +08:00
truewhile 0ab4693b92 优化 2026-10-29 22:11:28 +08:00
truewhile db516ded61 优化 2026-10-29 00:09:00 +08:00
truewhile 416d4c8c3b 优化 2026-10-27 22:14:40 +08:00
truewhile 78cfe0aba2 优化 2026-10-24 13:51:25 +08:00
truewhile 60ed34d5b1 优化 2026-10-24 00:22:10 +08:00
truewhile 31e775b9ba 优化 2026-10-24 00:12:20 +08:00
truewhile bf35bd3d02 优化 2026-10-23 23:11:05 +08:00
truewhile 0a84031d06 fix(player): 方向键不再唤出控制栏,仅续期隐藏计时器
方向键调整进度/音量时,若控制栏隐藏则保持隐藏,若可见则只刷新自动隐藏计时器,避免连续按键中途消失。
2026-10-22 17:11:02 +08:00
truewhile 9ccf60427a 优化 2026-10-22 15:14:00 +08:00
truewhile 54b0e33968 优化 2026-10-22 00:44:37 +08:00
147 changed files with 15188 additions and 570 deletions
+8
View File
@@ -113,6 +113,14 @@ func (a *application) start() error {
a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
// 一次性删除旧版解析误写的「整集片头/回顾」区间(end_ms <= start_ms),
// 否则升级后播放器仍会一开播就弹跳过按钮。
if cleaned, err := a.services.NormalizeImpossibleSegments(context.Background()); err != nil {
a.logger.Warn("impossible skip segment cleanup failed", zap.Error(err))
} else if cleaned > 0 {
a.logger.Info("impossible skip segments removed", zap.Int("rows", cleaned))
}
if err := a.services.Auth.SeedAdmin(context.Background()); err != nil {
a.logger.Warn("seed admin failed", zap.Error(err))
}
+636
View File
@@ -0,0 +1,636 @@
# 115 云下载 → STRM 同步 → 自动刮削 · 实施方案
| 项 | 内容 |
| :--- | :--- |
| 状态 | 设计已定稿,待实现(P0 未开工) |
| 适用版本 | MeBox 当前主线(`internal/service/strm_*`、`internal/service/cloud115/*`) |
| 关联文档 | `115doc/115开放平台/API列表/云下载/*`(仓库内离线文档) |
| 最后核对 | 本文所有代码事实均按仓库当前代码逐条核对,行号仅供定位参考 |
---
## 1. 目标与范围
### 1.1 目标
在「115 媒体库映射」(即 STRM 管理中的一条 `StrmSyncPath`:115 目录 → 本地目录 .strm)之上提供云下载入口,用户提交磁力 / BT 种子 / ed2k / HTTP 链接后:
1. 任务提交到 115 离线(云下载);
2. 后台轮询 115 任务状态;
3. 任务完成后**自动触发**该同步目录的 STRM 增量同步;
4. 同步完成后**自动扫描**对应媒体库,生成 Media 行;
5. **自动刮削**新增媒体,无需用户再点一次。
全程无需离开 MeBox,链路上的每个阶段在任务列表里可见。
### 1.2 明确不做(本期范围外)
| 项 | 决定 | 原因 |
| :--- | :--- | :--- |
| 磁力链接选择部分文件下载 | **不做** | 开放平台 `/open/offline/add_task_urls` 无文件选择参数,且 `/open/offline/torrent` 只接受已上传种子的 `torrent_sha1` + `pick_code`,无法解析 magnet。见第 6 节 |
| `.torrent` 勾选文件 UI | **不做** | 用户已确认本期不做选文件;种子按全选提交 |
| 磁力转种子(DHT 抓 metadata) | **不做** | 需引入 torrent 依赖与常驻 DHT 网络行为,本期不引入新依赖 |
| 非 115 网盘的离线下载 | **不做** | CD2 / OpenList / 本地目录无对应能力 |
| 普通用户自助使用 | **不做** | 首期仅管理员(沿用 `AdminRequired` + `can_manage_strm` 语义),权限位见 9.3 |
| 下载完成后自动整理/重命名(Organize) | **不做** | 与 STRM 映射模型冲突,`.strm` 必须与远端路径一一对应 |
### 1.3 非目标(但需保证不被破坏)
- 现有 STRM 手动同步 / Cron 同步 / 下载队列 / 上传队列行为不变;
- 115 令牌自动刷新机制不得被第二个客户端实例破坏(见 3.3 关键约束 K1);
- 刮削不重复刷已 `matched` 的媒体(沿用 `EnqueueLibrary` 的候选过滤)。
---
## 2. 现状核实
### 2.1 已具备
| 能力 | 位置 | 说明 |
| :--- | :--- | :--- |
| 115 OpenAPI 客户端 | `internal/service/cloud115/client.go`、`const.go:16` | `OpenClient`,`ProAPIBase = https://proapi.115.com`,`doJSON` 内建重试与令牌刷新 |
| 115 授权(PKCE/扫码)与令牌落库 | `cloud115/oauth.go`、`service/strm_115_oauth.go` | `OnTokenRefreshed` → `persist115Tokens` 写回 `StrmAccount.Config` |
| 115 文件上传 / 秒传 | `cloud115/upload.go:217` | `Upload(ctx, filePath, parentCID, signKey, signVal)` → `UploadCompleteResult{FileId, PickCode, Sha1}` |
| 115 目录创建 / 查找 | `cloud115/upload.go:318`(`MkDir`)、`cloud115/open.go:126`(`FindNamedContentInParent`) | 可用于建「云下载/种子文件」暂存目录 |
| 115 全局限流/熔断 | `cloud115/throttle_manager.go` | `GetGlobalThrottleManager()` |
| STRM 同步(增量/全量) | `service/strm_sync.go:82` `StartSync` | 已运行时返回「该目录正在同步中」,不排队 |
| STRM 目录/记录/队列数据层 | `model/strm.go`、`repository/strm_repository.go` | — |
| 扫描 + 自动刮削入口 | `service/scanner_scan.go`、`scanner_post_scan.go:19` | `startAutoScrape` 受 `scrape.auto_on_scan` 全局开关约束 |
| 刮削入队 | `service/scraper_queue.go:219` `EnqueueLibrary` | 仅挑 `scrape_status` 为空的 `pending` 行,天然幂等、不会重刷已匹配 |
| 周期任务框架 | `service/scheduler.go:118` `scheduledJob` | 新增 job 只需在 `Start` 里 append |
| 服务装配 | `service/service_builder.go:148` | `NewStrmService` 之后才有 `Cloud115`,可作为依赖注入范式 |
### 2.2 缺口(本方案要补的)
| 缺口 | 影响 |
| :--- | :--- |
| 全仓无任何离线/云下载代码(`grep -r "offline" internal/service/cloud115/` 无命中) | 需从零封装 4 个 API |
| `finishSync` 结束后**没有任何回调**(`strm_sync.go:323`) | 「同步完成 → 扫描」链路是断的,必须新增 hook |
| 无「本地路径 → 媒体库」反查 | 需新增 repository 查询 |
| 无 `.torrent` multipart 上传端点(现有仅 reader 书源、DB 恢复用到 `FormFile`) | 需新增端点 + 上传大小限制 |
| 无云下载任务表 | 需新增模型 + 仓储 + 迁移注册 |
---
## 3. 总体设计
### 3.1 链路时序
```
[前端] 提交(多行链接 / .torrent 文件)+ 选择目标 STRM 同步目录
│
▼
[CloudDownloadService.Submit]
│ 1) 校验账号可用、同步目录启用
│ 2) 配额预检 GET /open/offline/get_quota_info
│ 3) .torrent: 上传到「云下载/种子文件」→ POST /open/offline/torrent 拿 info_hash
│ 4) POST /open/offline/add_task_urls(批量 ≤115/批)或 /open/offline/add_task_bt
▼
[DB] CloudDownloadTask: status=submitted(逐条记录每个链接的受理结果)
│
▼
[Scheduler: cloud_download_poll 每 60s]
│ GET /open/offline/get_task_list?page=N 逐页
│ 按 info_hash(优先)/ url(回退)匹配本地任务
│ status 0/1 → downloading(percentDone) | 2 → succeeded | -1 → failed
▼
[Pipeline: succeeded 任务]
│ A. 收集受影响 syncPathID(30s 去抖合并)
│ B. StrmService.StartSync(syncPathID, incremental)
▼
[StrmService.finishSync] → SetSyncFinishHook(ok)
│ C. 反查 LocalPath 对应的媒体库 → Scanner.ScanLibrary(libraryID)
│ D. Scraper.EnqueueLibrary(libraryID, 默认 options)(仅 pending 行)
▼
[DB] 回写 Stage=done;前端列表展示各阶段结果
```
### 3.2 模块职责
| 模块 | 职责 | 明确不负责 |
| :--- | :--- | :--- |
| `cloud115/offline.go` | 纯 API 封装:参数拼装、响应解析、错误码映射 | 不碰数据库、不做编排 |
| `CloudDownloadService` | 输入解析、校验、提交、状态机、轮询、编排 | 不直接读写 .strm 文件 |
| `StrmService` | 现有同步逻辑 + 新增完成回调 | 不感知云下载概念 |
| `ScannerService` / `ScraperService` | 现有扫描/刮削逻辑 | 不感知云下载概念 |
### 3.3 关键约束(实现时必须遵守)
**K1 — 必须复用 StrmService 已缓存的 115 客户端,禁止自行 `NewOpenClient`。**
`strm_service.go:524` 的 `newProvider` 里给 `OpenClient` 挂了 `OnTokenRefreshed` → `persist115Tokens`。115 的 `refresh_token` 是一次性轮转的:若云下载服务另建一个客户端,两边各自刷新会互相作废,最终把账号刷成「授权已失效」。
落地方式:给 `StrmService` 增加导出方法
```go
// Open115Client 返回账号已缓存的 115 客户端(含令牌自动刷新与落库)。
func (s *StrmService) Open115Client(ctx context.Context, accountID string) (*cloud115.OpenClient, error)
```
内部复用 `providerFor` + `cloud.OpenAPI115Provider` 断言(与 `strm_sync.go:379` 的用法一致)。
**K2 — 去抖。** `walk115Flat`(`strm_sync.go:682`)是整树自适应拉取,不是只看新目录。多个任务在短时间内完成时,同一 syncPath 只应同步一次。
**K3 — 阶段失败隔离。** STRM 同步失败不得把 115 下载状态改成失败;反之亦然。下载状态与后处理阶段分别落在不同字段(见 4.1)。
**K4 — 同步不排队。** `StartSync` 在运行中直接报错,Pipeline 必须自己实现「等待 + 重投」,不能靠重试打满日志。
**K5 — 所有 115 调用走全局 throttle。** 轮询在无活跃任务时不得发起请求。
---
## 4. 数据模型
### 4.1 `model.CloudDownloadTask`(新表 `cloud_download_tasks`)
```go
// internal/model/cloud_download.go
type CloudDownloadTask struct {
Base
AccountID string `gorm:"size:36;index" json:"account_id"`
SyncPathID string `gorm:"size:36;index" json:"sync_path_id"` // 绑定的 STRM 同步目录
LibraryID string `gorm:"size:36;index" json:"library_id"` // 由 LocalPath 反查得到,可为空
Provider string `gorm:"size:32" json:"provider"` // 固定 cloud115
SourceType string `gorm:"size:16;index" json:"source_type"` // magnet / torrent / ed2k / http / ftp
SourceURL string `gorm:"type:text" json:"source_url"` // 原始链接(.torrent 时存文件名)
SourceKey string `gorm:"size:512;index" json:"source_key"` // 归一化后的匹配键:大写 info_hash 或 trim 后的 url
InfoHash string `gorm:"size:64;index" json:"info_hash"`
TorrentName string `gorm:"size:512" json:"torrent_name,omitempty"`
TorrentSha1 string `gorm:"size:64" json:"torrent_sha1,omitempty"` // .torrent 路径用
PickCode string `gorm:"size:128" json:"pick_code,omitempty"` // .torrent 路径用
WpPathID string `gorm:"size:64" json:"wp_path_id"` // 115 目标目录 ID(= syncPath.RemotePath)
SavePath string `gorm:"size:512" json:"save_path,omitempty"` // 相对 wp_path_id 的子路径(本期留空)
Status string `gorm:"size:16;index" json:"status"` // queued/submitted/downloading/succeeded/failed/canceled
Percent int `json:"percent"`
SizeBytes int64 `json:"size_bytes"`
Error string `gorm:"size:1024" json:"error"`
Stage string `gorm:"size:16;index" json:"stage"` // pending/waiting_sync/syncing/scanning/scraping/done/na
StageError string `gorm:"size:1024" json:"stage_error"`
StrmRecordID string `gorm:"size:36" json:"strm_record_id,omitempty"`
ScanAdded int `json:"scan_added"`
ScrapeQueued int `json:"scrape_queued"`
RetryCount int `json:"retry_count"`
LastPollAt *time.Time `json:"last_poll_at,omitempty"`
NextPollAt *time.Time `gorm:"index" json:"next_poll_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
```
注册点:`model.AllModels()`(`internal/model/model.go:37`)、`repository.Container`(`internal/repository/repository.go:11`)、`service.Container`(`internal/service/service.go:19`)。
> **不复用 `StrmDownloadTask`**:那是「远端元数据 → 本地」的文件搬运队列(`model/strm.go`),语义完全不同,混用会让 `/admin/strm/downloads` 列表出现云下载任务。
### 4.2 状态机
**Status(115 下载侧)**
| 值 | 进入条件 | 终态 |
| :--- | :--- | :--- |
| `queued` | 本地已建行,尚未提交 115 | 否 |
| `submitted` | 115 已受理(`data[].state=true`) | 否 |
| `downloading` | `get_task_list` 返回 status 0 或 1 | 否 |
| `succeeded` | status 2 | **是**,进入 Pipeline |
| `failed` | status -1 / 提交被拒 / 超时(默认 72h) | **是** |
| `canceled` | 用户取消 | **是** |
**Stage(后处理侧)**
| 值 | 含义 |
| :--- | :--- |
| `pending` | 尚未开始(未成功) |
| `waiting_sync` | 已成功,等待去抖窗口结束或等待同一目录的同步结束 |
| `syncing` | STRM 增量同步执行中 |
| `scanning` | 媒体库扫描中 |
| `scraping` | 刮削已入队 |
| `done` | 全链路完成 |
| `na` | 同步目录已删除/禁用,跳过后续(不算失败) |
| `failed` | 阶段失败,`StageError` 记录原因 |
> Stage 失败**不回滚** Status;用户可对 Stage 失败的任务点「重做后处理」。
---
## 5. 115 开放平台对接
### 5.1 接口清单
| 用途 | 方法 | 路径 | 关键参数 | 关键返回 |
| :--- | :--- | :--- | :--- | :--- |
| 添加链接任务 | POST | `/open/offline/add_task_urls` | `urls`(换行分隔,≤115 条)、`wp_path_id` | `data[]{state,code,message,info_hash,url}` |
| 解析 BT 种子 | POST | `/open/offline/torrent` | `torrent_sha1`、`pick_code` | `data{file_size,torrent_name,file_count,info_hash,torrent_filelist[]{size,path,wanted}}` |
| 添加 BT 任务 | POST | `/open/offline/add_task_bt` | `info_hash`、`wanted`、`save_path`、`torrent_sha1`、`pick_code`、`wp_path_id` | `data[]`(成功为空数组) |
| 任务列表 | GET | `/open/offline/get_task_list` | `page` | `data{page,page_count,count,tasks[]{...}}` |
| 配额信息 | GET | `/open/offline/get_quota_info` | 无 | `data{package[]{surplus,used,count,name,expire_info[]},count,surplus,used}` |
| 删除任务(P2) | POST | `/open/offline/del_task` | `info_hash`、`del_source_file` | `data[]` |
| 清空任务(P2) | POST | `/open/offline/clear_task` | `flag`(0~5) | `data[]` |
请求形态:`add_task_*` / `del_task` / `clear_task` / `torrent` 均为 **multipart/form-data**;`get_*` 为 GET query。全部走 `Authorization: Bearer <access_token>`,即现有 `doAuthJSON` 通道。
### 5.2 任务列表字段(轮询用)
| 字段 | 类型 | 用途 |
| :--- | :--- | :--- |
| `info_hash` | string | 首选匹配键 |
| `url` | string | 回退匹配键(链接任务) |
| `status` | int | -1 失败 / 0 分配中 / 1 下载中 / 2 成功 |
| `percentDone` | int | 进度(**注意驼峰**,见 5.4) |
| `size` | int | 任务总字节 |
| `name` | string | 任务名,用于 UI |
| `file_id` / `delete_file_id` | string | 完成后源文件/文件夹 ID(P2 裁剪用) |
| `wp_path_id` | string | 落盘父目录,可用于校验目录一致性 |
| `add_time` / `last_update` | int | 超时判断 |
### 5.3 错误码映射(要落到用户可读文案)
| 码 | 115 文案 | MeBox 提示 |
| :--- | :--- | :--- |
| `1000012` | 云下载配额已用完 | 「115 云下载配额已用尽,请在 115 端购买/领取配额后重试」 |
| `91006` | 存储空间不足 | 「115 存储空间不足」 |
| `20018` | 文件不存在或已删除 | 「种子文件找不到(可能已被清理),请重新上传 .torrent」 |
| `1000011` | 链接数量超过 115 个 | 本地已按 115 条切批,正常不应触发;触发则记 warning 并重切 |
| `990002` | 参数错误 | 「提交参数不被 115 接受」,附原始 message |
| `980004` | 操作失败 | 可重试,走退避 |
| 无权限/未开通 | —— | 「当前 115 应用未开通云下载权限」,并提示到开放平台申请(见风险 R1) |
### 5.4 实现陷阱(必须按此实现)
1. **`percentDone` 是驼峰**,`get_task_list` 其余字段是 snake_case。Go struct tag 必须写 `json:"percentDone"`,写错会永远 0 进度。
2. **`data[].info_hash` 仅任务成功受理时返回**,可能为空 → 必须同时保存提交时的原始 `url` 做回退匹配,并对 url 做归一化(去首尾空白、统一小写协议头、去掉 `&` 后跟踪参数?**不做过度归一化**,仅 trim,避免误合并)。
3. **`wanted` 索引基准未确认**(见 11 节实测项)。本期不做选文件,实现统一传 `wanted="0"`(官方文档唯一明确允许的取值),待真机确认后再决定是否改为全索引拼接。
4. **`add_task_urls` 单批 ≤115**,超了服务端报 `1000011`;本地必须切批。
5. **`wp_path_id=0` 表示根目录**,绝不能把「未选择目录」当 0 提交,否则文件落到 115 根目录,STRM 永远扫不到 → 提交前强校验 `WpPathID != "" && != "0"`。
6. **`get_task_list` 只支持 page,无 page_size**,需按 `page_count` 翻页;轮询设页数上限(默认 10 页)防止账号任务堆积把一轮轮询拖爆。
---
## 6. 为什么磁力不能选文件(结论留档)
| 接口 | 能否选择文件 | 依据 |
| :--- | :--- | :--- |
| `add_task_urls`(磁力/ed2k/http) | ❌ 无 wanted 参数 | `115doc/.../添加云下载链接任务.md` 请求参数仅 `urls`、`wp_path_id` |
| `torrent`(解析种子) | ✅ 返回 `torrent_filelist[].wanted` | `解析BT种子.md` 返回字段表 |
| `add_task_bt`(提交 BT) | ✅ `wanted` 必填 | `添加云下载BT任务.md` 请求参数表 |
`torrent` 的入参是 **`torrent_sha1` + `pick_code`**,只能解析「已上传到本人 115 空间的 .torrent 文件」,不接受 magnet / info_hash。开放平台没有「解析磁力 → 文件列表」这一步,因此磁力链路从入口就没有选文件能力。
**若要支持磁力选文件**,唯一路径是服务端自行把 magnet 抓成 .torrent(DHT metadata),属新增外部依赖与常驻网络行为,本期不做,记录为 P3 候选。
---
## 7. 后端实现
### 7.1 文件清单
**新增**
| 文件 | 内容 |
| :--- | :--- |
| `internal/model/cloud_download.go` | `CloudDownloadTask` 模型 + 状态常量 |
| `internal/repository/cloud_download_repository.go` | CRUD、按状态取活跃任务、按阶段取待推进任务、统计 |
| `internal/service/cloud115/offline.go` | 4+2 个 API 封装、响应结构体、错误码常量与 `OfflineError` 类型 |
| `internal/service/cloud_download.go` | `CloudDownloadService` 主体:构造、提交、取消、重试、列表、配额 |
| `internal/service/cloud_download_input.go` | 输入解析(多行/类型判定/去重/切批 ≤115)、匹配键归一化 |
| `internal/service/cloud_download_submit.go` | 链接提交 + `.torrent` 上传解析 → `add_task_bt`、暂存目录查找/创建 |
| `internal/service/cloud_download_poll.go` | 轮询、状态映射、超时熔断、远程任务与本地记录对账 |
| `internal/service/cloud_download_pipeline.go` | Stage 编排、去抖、等待同步、扫描、刮削、失败隔离 |
| `internal/service/scheduler_cloud_download.go` | `jobCloudDownloadPoll` |
| `internal/handler/cloud_download.go` | REST handler + multipart 种子上传 |
| `internal/handler/routes_cloud_download.go` | 路由注册(在 `registerAdminStrmRoutes` 同级新增 `registerAdminCloudDownloadRoutes`) |
| `web/src/types/cloudDownload.ts` | 前端类型 |
| `web/src/api/cloudDownload.ts` | 前端 API |
| `web/src/pages/CloudDownloadPage.tsx` | 页面(提交面板 + 任务列表 + 配额) |
**修改**
| 文件 | 改动 | 风险 |
| :--- | :--- | :--- |
| `internal/model/model.go` | `AllModels()` 追加 `&CloudDownloadTask{}` | 低(AutoMigrate 新增表) |
| `internal/repository/repository.go` | `Container` 增加 `CloudDownload *CloudDownloadRepository` | 低 |
| `internal/service/service.go` | `Container` 增加 `CloudDownload *CloudDownloadService`,`Start/Stop` 联动 | 低 |
| `internal/service/strm_service.go` | 新增 `SetSyncFinishHook`、`Open115Client` | 中(新增导出方法,不改现有逻辑) |
| `internal/service/strm_sync.go` | `finishSync` 末尾触发 hook(异步) | **中**:hook 里必须自带 recover + 超时,绝不能阻塞或 panic 影响同步协程 |
| `internal/service/service_builder.go` | 构造 `CloudDownload`(在 `Strm` 之后),注入 Strm/Scan/Scraper/Tasks/Hub;回调 hook | 低 |
| `internal/service/scheduler.go` | `Start()` 里 append `cloud_download_poll` job(仅在 `CloudDownload != nil` 时注册) | 低 |
| `internal/repository/library_repository.go` | 新增 `FindEnabledRootsByLocalPath(ctx, localPath)`(归一化 + 前缀匹配) | 低 |
| `web/src/appRoutes.tsx`、`web/src/components/layoutNavigation.ts` | 路由 + 侧边栏入口 | 低 |
### 7.2 服务对外 API
```go
type CloudDownloadService struct{ /* cfg, log, repos, strm, scanner, scraper, tasks, hub */ }
func (s *CloudDownloadService) Submit(ctx context.Context, req CloudDownloadSubmit) (*CloudDownloadSubmitResult, error)
func (s *CloudDownloadService) SubmitTorrent(ctx context.Context, req CloudDownloadTorrentSubmit) (*CloudDownloadSubmitResult, error)
func (s *CloudDownloadService) List(ctx context.Context, q CloudDownloadQuery) (*CloudDownloadPage, error)
func (s *CloudDownloadService) Cancel(ctx context.Context, id string) error
func (s *CloudDownloadService) Retry(ctx context.Context, id string) error // 失败任务重新提交
func (s *CloudDownloadService) RetryPipeline(ctx context.Context, id string) error // 仅重做后处理
func (s *CloudDownloadService) Delete(ctx context.Context, id string) error
func (s *CloudDownloadService) ClearFinished(ctx context.Context) (int64, error)
func (s *CloudDownloadService) Quota(ctx context.Context, accountID string) (*OfflineQuota, error)
func (s *CloudDownloadService) Start(ctx context.Context) // 恢复中断任务
func (s *CloudDownloadService) Stop()
```
`CloudDownloadSubmit`:`AccountID`、`SyncPathID`、`URLs []string`(一次提交多条)。
`CloudDownloadSubmitResult`:逐条 `{source, task_id, ok, error}`,**部分成功必须返回 200 + 明细**。
### 7.3 提交校验(顺序不可乱)
1. `SyncPathID` 存在、`Enabled`、`Provider == cloud115`、`AccountID` 非空、`RemotePath` 为有效目录 ID 且 ≠ `"0"`;
2. 账号存在且 `Enabled`,`Open115Client` 可取得;
3. 配额预检:`get_quota_info` → `surplus <= 0` 时直接拒绝(批量多链接时按条数预估,无法精确预估大小,只做「配额是否耗尽」闸门);
4. 输入解析:按行拆分 → 类型判定 `magnet:?xt=urn:btih:` / `ed2k://` / `http(s)://` / `ftp://` → trim → 去重(同批内 + 与库内 `submitted/downloading` 任务去重);
5. 切批:每批 ≤115 条,逐批调用 `add_task_urls`;
6. 逐条落库:`data[].state=true` → `submitted`;失败 → `failed` + 错误码映射文案。
### 7.4 轮询实现要点
```
每 tick(默认 60s):
1. 取所有 status IN (submitted, downloading) 的任务,按 account_id 分组
2. 无活跃任务 → 直接返回(不打 115 接口)
3. 对每个账号:
翻页取 get_task_list(page=1..min(page_count, 10))
构建索引:info_hash(大写) → task,url(trim) → task
逐条匹配本地记录:
- info_hash 命中优先
- 否则 url 命中
- 都未命中 → 记 LastPollAt,未超时前不判失败(115 受理有延迟)
状态映射 + 更新 Percent/SizeBytes/Name
status=2 → succeeded + CompletedAt + Stage=pending
status=-1 → failed(Error 用 115 文案)
4. 超时:submitted/downloading 且 add_time 早于 now-72h → failed「下载超时」
```
### 7.5 Pipeline 实现要点
```
推进循环(可挂在同一 tick 之后,也可独立每 15s):
A. 取 status=succeeded AND stage IN (pending, waiting_sync, failed) 的任务,按 sync_path_id 分组
- 每组内若有任务 completed_at 在去抖窗口内(默认 30s)→ 本轮跳过(等凑批)
B. 对每个 syncPath:
- 若 IsSyncRunning(pathID) → 全部置 waiting_sync,下一轮再看
- 否则 StartSync(pathID, incremental),置 syncing,记录 StrmRecordID(取该 path 最新记录)
C. 同步完成后(hook):
- 置 scanning → 反查 library(LocalPath 与 LibraryRoot.Path 归一化后相等或为其子目录)
- 无匹配库 → Stage=na + StageError「本地目录未加入任何媒体库」
- ScanLibrary(libraryID) → ScanAdded
- 置 scraping → EnqueueLibrary(libraryID, ScrapeOptions{}) → ScrapeQueued
- 置 done
D. 任一步失败:Stage=failed + StageError,不重试(等待用户点「重做后处理」)
E. 进程重启:Start() 时把 syncing/scanning/scraping 的僵尸阶段重置为 pending(参考 StrmService.recoverInterruptedSyncs 的写法)
```
去抖细节:以 `syncPathID` 为单位,服务内存里维护 `lastTrigger[pathID] = time`;窗口内到达的任务全部 `waiting_sync`,窗口结束时由首个触发者发起同步。
### 7.6 STRM 完成回调
```go
// strm_service.go
func (s *StrmService) SetSyncFinishHook(fn func(ctx context.Context, syncPathID, status string)) { ... }
```
在 `finishSync`(`strm_sync.go:323`)**落库完成之后**调用:
```go
if hook := s.syncFinishHook(); hook != nil {
helper.Go(s.log, "strm.syncFinishHook", func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
hook(ctx, p.ID, status)
})
}
```
要点:**异步 + 自带 recover + 超时**,绝不能让 hook 的 panic 或阻塞影响同步协程;也不要在 `finishSync` 里同步调用扫描(同步耗时长,会拖住同步收尾与 Pending 记录)。
### 7.7 调度与配置项
设置项(沿用 `repo.Setting`,键名与现有 `scrape.*` / `strm.*` 风格一致):
| 键 | 默认 | 说明 |
| :--- | :--- | :--- |
| `cloud_download.poll_interval_sec` | `60` | 轮询间隔(30~600) |
| `cloud_download.sync_debounce_sec` | `30` | 同一同步目录的去抖窗口(0~300) |
| `cloud_download.auto_scrape` | `true` | 同步后是否自动刮削 |
| `cloud_download.task_timeout_hours` | `72` | 下载超时判定 |
| `cloud_download.torrent_max_mb` | `20` | `.torrent` 上传大小上限 |
| `cloud_download.max_pages` | `10` | 单轮轮询最大翻页数 |
`auto_scrape=true` 时**不受** `scrape.auto_on_scan` 全局开关约束(用户显式提交了下载,期望链路闭环);但若 `Scraper.AnyEnabled()==false`(无任何刮削源),Stage 直接置 `done` 并在 `StageError` 记「未配置刮削源」。
### 7.8 HTTP 路由与鉴权
挂在 `registerAdminStrmRoutes` 同组(`admin`,已带 `AuthRequired` + `AdminRequired`):
```go
admin.GET("/cloud-downloads", listCloudDownloadsHandler(svc))
admin.POST("/cloud-downloads", createCloudDownloadsHandler(svc))
admin.POST("/cloud-downloads/torrent", uploadCloudDownloadTorrentHandler(svc)) // multipart
admin.GET("/cloud-downloads/quota", cloudDownloadQuotaHandler(svc))
admin.POST("/cloud-downloads/:id/retry", retryCloudDownloadHandler(svc))
admin.POST("/cloud-downloads/:id/retry-pipeline", retryCloudDownloadPipelineHandler(svc))
admin.POST("/cloud-downloads/:id/cancel", cancelCloudDownloadHandler(svc))
admin.DELETE("/cloud-downloads/:id", deleteCloudDownloadHandler(svc))
admin.POST("/cloud-downloads/clear-finished", clearFinishedCloudDownloadsHandler(svc))
```
multipart 端点:`file`(.torrent)+ `account_id` + `sync_path_id`;实现需 `c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxMB<<20)`,并校验文件头(BT 种子是 bencode,以 `d` 开头,含 `announce` / `info`)。
---
## 8. 前端实现
### 8.1 页面结构(`CloudDownloadPage.tsx`)
**顶部:提交面板**
- 目标同步目录下拉:复用 `GET /admin/strm/paths`,只列 `provider=cloud115 && enabled`,展示 `name + remote_display_path + local_path`;
- 账号显示:由所选同步目录自动带出(`account_id`),无需二次选择;
- 链接文本框:多行粘贴,右下角实时显示「识别到 N 条:磁力 x / ed2k y / http z」;
- `.torrent` 拖拽/选择:单文件,提交时走 multipart 端点;
- 配额条:`surplus/count`,耗尽时提交按钮禁用并提示;
- 提交结果:逐条成功/失败明细(不合并成一句「提交失败」)。
**下方:任务列表**
- 列:来源(截断显示 + tooltip 全量)、目标目录、Size、下载进度、**阶段徽标**(下载中 / 等待同步 / 同步中 / 扫描中 / 刮削中 / 完成 / 失败)、时间、操作;
- 操作:取消(仅下载中)、重试(失败)、重做后处理(Stage 失败)、删除(终态);
- 批量:清空已完成;
- 刷新:复用现有 WS Hub(`hub.Publish("cloud_download", …)`),断线时降级为 5s 轮询。
### 8.2 路由与导航
- `appRoutes.tsx`:`{ path: 'cloud-downloads', element: <CloudDownloadPage />, adminOnly: true }`
- `layoutNavigation.ts`:`LAYOUT_NAV_ITEMS` 中 `STRM 管理` 之后加 `{ to: '/cloud-downloads', label: '云下载', icon: CloudDownload, adminOnly: true }`
- `StrmManagePage` 顶部工具区加一个「云下载」跳转按钮,形成入口闭环。
---
## 9. 权限与安全
### 9.1 鉴权
首期仅管理员:路由挂在 `admin` 组,`AdminRequired` 中间件已覆盖。
### 9.2 输入安全
- 链接只做**字符串拼接转发**,服务端绝不主动发起对这些 URL 的请求(无 SSRF 面);
- `.torrent` 文件仅落临时目录 → 上传 115 → 删除,不本地解析 bencode(P1 若要预览文件名需加 bencode 解析,届时必须限大小与深度);
- 上传文件名禁用 `..` / 路径分隔符;
- `save_path` 本期固定为空(留字段),避免路径穿越争议。
### 9.3 后续(P2)
`model.UserPermission` 增加 `CanUseCloudDownload bool`,配套 handler 从 `AdminRequired` 换成权限中间件,并给普通用户做配额上限(否则一个用户可以把账号的 115 云下载配额打空)。
---
## 10. 测试计划
### 10.1 单元测试(必须)
| 目标 | 用例 |
| :--- | :--- |
| 输入解析 | 多行混合、空行、重复链接、首尾空白、大小写协议头、>115 条切批、非法行跳过并计数 |
| 状态映射 | status -1/0/1/2 → failed/downloading/downloading/succeeded;缺失 info_hash 时 url 回退命中;大小写 info_hash |
| 错误码映射 | 1000012 / 91006 / 20018 / 990002 文案与是否可重试 |
| 提交校验 | `RemotePath="0"`、账号禁用、同步目录禁用、配额耗尽 → 全部拒绝且不产生 115 请求 |
| 去抖 | 30s 内 3 个任务完成 → 只触发 1 次 `StartSync` |
| 等待同步 | `IsSyncRunning=true` → 全部 waiting_sync;结束后成功发起 |
| 阶段隔离 | 同步失败不改变 Status;扫描失败不改变 syncing 记录 |
| 僵尸恢复 | 重启后 syncing/scanning/scraping → pending |
测试风格沿用仓库现状:同包 `*_test.go`、`t.Context()`、httptest 模拟 115(参考 `internal/service/cloud115/cloud115_test.go` 的 mock 模式)。
### 10.2 集成测试
- `httptest` 起伪 115(`/open/offline/*`):验证提交 → 轮询 → 状态流转 → pipeline 触发(Strm 用 fake hook,Scanner/Scraper 用桩)。
- 现有测试回归:`go test ./internal/service/... ./internal/repository/... ./internal/handler/...`。
### 10.3 真机验收(不可省)
| # | 步骤 | 期望 |
| :--- | :--- | :--- |
| 1 | 提交一条小体积磁力(<200MB)绑定某 115 同步目录 | 任务 5s 内变 `submitted` |
| 2 | 等 115 完成 | 任务变 `succeeded`,随后 `syncing` |
| 3 | 同步完成 | 本地目录出现新 `.strm` 文件 |
| 4 | 自动扫描 | 媒体库新出现对应条目 |
| 5 | 自动刮削 | 条目 `scrape_status` 变 `matched`,海报正确 |
| 6 | 提交 `.torrent` 文件 | 同上,且 115 端任务名与种子内任务名一致 |
| 7 | 提交 ed2k 链接 | 同上(ed2k 无 info_hash,验证 url 回退匹配) |
| 8 | 提交 120 条链接 | 自动切 2 批,逐条结果完整 |
---
## 11. 待实测确认项
| # | 项 | 影响 | 处理 |
| :--- | :--- | :--- | :--- |
| 1 | `add_task_bt` 的 `wanted` 是 0-based 还是 1-based | 后续若开放选文件,索引拼接依赖此语义 | 真机用一个 3 文件种子验证;当前实现统一传 `"0"`,不受此影响 |
| 2 | `wanted="0"` 是「全部」还是「第 0 个」 | 决定 `.torrent` 任务是否只下一个文件 | **必测**:当前实现依赖它表示「全部」 |
| 3 | `MkDir` 对已存在目录的返回 | 暂存目录重复创建 | 先 `FindNamedContentInParent` 查找,未命中再 `MkDir`;两者都失败则回退到目标同步目录 |
| 4 | 当前 115 应用是否已开通云下载权限 | 全功能可用性 | 提交前 `get_quota_info` 探测;无权限时给出「去开放平台申请」指引 |
| 5 | 磁力整体提交时 115 默认勾选哪些文件 | 是否漏下样片/字幕 | 不影响链路,只在文档说明 |
| 6 | `get_task_list` 单页实际返回条数 | 翻页策略 | 首轮实测后调整 `max_pages` 默认值 |
---
## 11.5 实现状态(P0 已落地)
已完成(后端 + 前端最小可用):
| 层 | 文件 | 说明 |
| :--- | :--- | :--- |
| 模型 | `internal/model/cloud_download.go` | `CloudDownloadTask` + 状态/阶段常量,已注册 `AllModels()` |
| 仓储 | `internal/repository/cloud_download_repository.go` | 分页查询、活跃任务去重、阶段推进、僵尸阶段重置 |
| 115 协议 | `internal/service/cloud115/offline.go` | 7 个接口封装;`client.go` 新增 multipart 请求构造并复用限流/刷新/错误封装 |
| 服务 | `internal/service/cloud_download*.go` | 输入解析、提交、轮询、去抖、Pipeline(同步→扫描→刮削) |
| 调度 | `internal/service/scheduler_cloud_download.go` | `cloud_download_poll` 周期任务 |
| 打通 | `internal/service/strm_service.go` / `strm_sync.go` | `Open115Client` 复用缓存客户端;`SetSyncFinishHook` 异步回调 |
| HTTP | `internal/handler/cloud_download.go` | 9 个管理员接口 + multipart 种子上传 |
| 前端 | `web/src/pages/CloudDownloadPage.tsx` 等 | 提交面板、配额、任务列表、重试/取消/删除、5s 自动刷新 |
P0 范围外(保留为 P1/P2):
- 设置项 UI(当前通过 `Setting` 表键值生效,默认轮询 60s / 去抖 30s / 超时 72h);
- WebSocket 实时进度(当前为 5s 轮询);
- 普通用户权限位与单用户配额上限;
- 远端任务清理(`del_task` / `clear_task`)在 UI 上无入口(取消任务时会尽力调用 `del_task`)。
验证证据:
- `go build ./...`、`go vet` 通过;
- 新增单测:`internal/service/cloud115/offline_test.go`(7 项,含 multipart 格式与 `percentDone` 解析)、`internal/service/cloud_download_test.go`(9 项,含解析/去抖/目录匹配/校验);
- 前端 `tsc --noEmit`、`eslint`、`vite build` 通过;
- 3 个既有测试在本机 HEAD 上同样失败(Windows 文件占用与 SQLite `VACUUM INTO` 环境问题),已用独立 worktree 复核,与本次改动无关。
## 12. 分期与验收
### P0(可跑通,约 1 个迭代)
- 模型 + 仓储 + 迁移注册
- `cloud115/offline.go`(4 个接口:add_task_urls / get_task_list / get_quota_info / torrent + add_task_bt)
- 提交(磁力/ed2k/http)+ 轮询 + 状态机
- STRM finish hook + Pipeline(同步 → 扫描 → 刮削)
- 调度 job + 设置项
- 前端最小页:提交面板 + 任务列表(阶段徽标、重试、删除)
- **验收**:10.3 的步骤 1~5、7、8 通过
### P1(体验完善)
- `.torrent` 上传端点 + 全选提交(10.3 步骤 6)
- 配额条、提交结果逐条明细
- 去抖、僵尸恢复、超时熔断
- WS 实时进度
- 分页列表 + 筛选(状态/目录)
### P2(可选)
- 远端任务清理(`del_task` / `clear_task`)
- 完成后删除暂存 `.torrent`
- 普通用户权限位 + 单用户配额上限
- Telegram 完成通知
### P3(本期不做,记录候选)
- 磁力选文件(服务端 DHT 抓 .torrent)
- 完成后按 file_id 裁剪不需要的文件
---
## 13. 风险与回滚
| # | 风险 | 影响 | 缓解 |
| :--- | :--- | :--- | :--- |
| R1 | 115 应用未开通云下载权限 | 功能不可用 | 提交前探测 + 明确文案;不影响现有 STRM 功能 |
| R2 | 令牌双实例互相作废 | 账号被标「授权已失效」 | **强约束 K1**:只复用 StrmService 缓存的客户端 |
| R3 | 频繁同步打爆 CPU/115 限流 | 服务卡顿、风控 | 去抖 30s + 最小间隔 + 同步运行中等待 + throttle_manager |
| R4 | 目录错配(`wp_path_id` 不在同步目录) | 下了但永远扫不到 | 提交时强制 `RemotePath` 非空非 0;UI 只允许选同步目录,不放开自由选目录 |
| R5 | finish hook panic 拖垮同步 | 同步记录永远 running | hook 异步 + `helper.Go` + recover + 独立超时 |
| R6 | 新增表迁移失败 | 启动失败 | `AutoMigrate` 加表无破坏性;出问题回滚只需删表 + 移除注册 |
| R7 | 单页页数过多导致轮询耗时 | 轮询堆积 | `max_pages` 上限;超限记 warning 并下轮继续 |
**回滚方式**:移除路由与侧边栏入口即可(功能面隐藏);表可保留(不影响其它子系统),或手动 `DROP TABLE cloud_download_tasks`。
---
## 14. 附录:核对依据
| 结论 | 依据位置 |
| :--- | :--- |
| 115 开放平台离线接口形态与参数 | `115doc/115开放平台/API列表/云下载/*.md` |
| STRM 同步入口与运行互斥 | `internal/service/strm_sync.go:82-138` |
| 同步收尾无回调 | `internal/service/strm_sync.go:323`(`finishSync`) |
| 115 目录整树拉取成本 | `internal/service/strm_sync.go:682`(`walk115Flat`) |
| 令牌刷新落库与单实例要求 | `internal/service/strm_service.go:524-556`、`strm_115_oauth.go:438` |
| 115 provider 断言复用范式 | `internal/service/strm_sync.go:378`(`cloud.OpenAPI115Provider`) |
| 上传种子可拿 PickCode | `internal/service/cloud115/upload.go:95-102,217` |
| 刮削候选只挑 pending | `internal/service/scraper_library.go:183-207` |
| 扫描自动刮削开关 | `internal/service/scanner_prune.go:279`、`scanner_post_scan.go:19` |
| 周期任务注册范式 | `internal/service/scheduler.go:118-210` |
| 模型迁移注册 | `internal/model/model.go:37`、`internal/database/schema_migration.go:34` |
| 服务装配顺序 | `internal/service/service_builder.go:148` |
+2 -3
View File
@@ -45,14 +45,13 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("logging.max_backups", 10)
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.images_max_size_mb", 2000)
v.SetDefault("cache.images_max_size_mb", 2048)
v.SetDefault("cache.images_originals_max_size_mb", 256)
v.SetDefault("cache.images_originals_ttl_hours", 168)
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 90)
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
v.SetDefault("cache.emby_latest_ttl_seconds", 900)
v.SetDefault("cache.reader_content_max_size_mb", 1024)
v.SetDefault("cache.reader_content_ttl_hours", 168)
v.SetDefault("cache.reader_files_ttl_hours", 720)
-3
View File
@@ -50,9 +50,6 @@ func (c *Config) normalize() error {
if c.Cache.ImagesOriginalsMaxSizeMB < 0 {
c.Cache.ImagesOriginalsMaxSizeMB = 0
}
if c.Cache.ImagesOriginalsTTLHours < 0 {
c.Cache.ImagesOriginalsTTLHours = 0
}
if c.Cache.MemoryMaxSizeMB <= 0 {
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
}
+9 -6
View File
@@ -120,14 +120,16 @@ type LoggingConfig struct {
// CacheConfig 控制磁盘转写缓存和进程内热缓存。
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
CacheDir string `mapstructure:"cache_dir"`
// ImagesMaxSizeMB 是整个磁盘缓存的总容量上限(MB),不是 images 子目录
// 的配额。参与统计的池为 hls、images、library-covers;超限时按该顺序淘汰
// 最旧文件。reader-audio 不在其中:它按 reader.audioMediaMaxBytes 的固定
// 配额(当前 1GiB)在写入时独立淘汰,不参与这里的总量。
// 0 表示不限制。
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
// ImagesOriginalsMaxSizeMB 限制 cache/images 下原图子配额(MB)。原图只是
// 生成缩略图的原料,丢了可以重新回源;0 表示不单独限制(只受总量约束)。
ImagesOriginalsMaxSizeMB int `mapstructure:"images_originals_max_size_mb"`
// ImagesOriginalsTTLHours 是原图的保留时长(小时),超过即删除;0 表示
// 不按时间淘汰。缩短它能让原图缓存保持在一个滚动窗口内。
ImagesOriginalsTTLHours int `mapstructure:"images_originals_ttl_hours"`
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
RedisURL string `mapstructure:"redis_url"`
@@ -136,7 +138,8 @@ type CacheConfig struct {
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
// 上造成秒级延迟。默认 900 秒;过期后仍会先返回陈旧副本并在后台重建,
// 新入库内容最迟 15 分钟后出现在最新列表(扫描完成会主动失效缓存)。
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
// ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。
ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"`
+5
View File
@@ -376,6 +376,11 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
`DROP INDEX IF EXISTS idx_media_library_release_active`,
`CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
// 虚拟剧集分组索引:series_id 为空的剧集行要按
// (season_num, episode_num, episode_fraction, created_at) 排序后做内存
// 分组。没有它 SQLite 只能全表扫描再建临时 B-tree,而 Emby 的
// 卡片/海报查询每次只关心其中一部剧(生产环境实测单次 1.3s)。
`CREATE INDEX IF NOT EXISTS idx_media_virtual_series_group ON media(season_num, episode_num, episode_fraction, created_at) WHERE deleted_at IS NULL AND (series_id IS NULL OR series_id = '')`,
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_series_active ON media(library_id, series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
+2 -2
View File
@@ -87,9 +87,9 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
svc.Transcoder.StopAll()
}
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" || req.Key == "cache.images_originals_ttl_hours" {
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" {
if svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "cache_cleanup")
}
}
if req.Key == "cache.memory_max_size_mb" && svc.Cache != nil {
+163
View File
@@ -0,0 +1,163 @@
// 云下载(115 离线下载)HTTP 接口。
//
// 全部挂在 /api/admin 下(管理员专用):首期不开放普通用户,否则单个用户
// 可以把账号的 115 云下载配额打空(见 docs/cloud-download-plan.md 第 9 节)。
package handler
import (
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// cloudDownloadUploadHardLimit 是 .torrent 上传的硬上限(字节)。
// 真实上限由设置项 cloud_download.torrent_max_mb 控制,这里只防内存打爆。
const cloudDownloadUploadHardLimit = 256 << 20
func registerAdminCloudDownloadRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/cloud-downloads", listCloudDownloadsHandler(svc))
admin.POST("/cloud-downloads", createCloudDownloadsHandler(svc))
admin.POST("/cloud-downloads/torrent", uploadCloudDownloadTorrentHandler(svc))
admin.GET("/cloud-downloads/quota", cloudDownloadQuotaHandler(svc))
admin.POST("/cloud-downloads/:id/retry", retryCloudDownloadHandler(svc))
admin.POST("/cloud-downloads/:id/retry-pipeline", retryCloudDownloadPipelineHandler(svc))
admin.POST("/cloud-downloads/:id/cancel", cancelCloudDownloadHandler(svc))
admin.DELETE("/cloud-downloads/:id", deleteCloudDownloadHandler(svc))
admin.POST("/cloud-downloads/clear-finished", clearFinishedCloudDownloadsHandler(svc))
}
func listCloudDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.Query("page"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
res, err := svc.CloudDownload.List(c.Request.Context(), repository.CloudDownloadQuery{
Status: c.Query("status"),
SyncPathID: c.Query("sync_path_id"),
AccountID: c.Query("account_id"),
Page: page,
PageSize: pageSize,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func createCloudDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req service.CloudDownloadSubmit
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 逐条结果里已经包含失败原因,因此部分失败仍返回 200,前端按 items 展示。
res, err := svc.CloudDownload.Submit(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func uploadCloudDownloadTorrentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, cloudDownloadUploadHardLimit)
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少种子文件(form 字段名 file):" + err.Error()})
return
}
f, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "打开种子文件失败:" + err.Error()})
return
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(f)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取种子文件失败:" + err.Error()})
return
}
res, err := svc.CloudDownload.SubmitTorrent(c.Request.Context(), service.CloudDownloadTorrentRequest{
AccountID: c.PostForm("account_id"),
SyncPathID: c.PostForm("sync_path_id"),
FileName: fileHeader.Filename,
Data: data,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func cloudDownloadQuotaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
quota, err := svc.CloudDownload.Quota(c.Request.Context(), c.Query("account_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, quota)
}
}
func retryCloudDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.CloudDownload.Retry(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func retryCloudDownloadPipelineHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.CloudDownload.RetryPipeline(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func cancelCloudDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.CloudDownload.Cancel(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func deleteCloudDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.CloudDownload.Delete(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func clearFinishedCloudDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.CloudDownload.ClearFinished(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
+113
View File
@@ -0,0 +1,113 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 客户端刷新首页时会用 IncludeItemTypes=Movie,Series,Video 一次取「最新」行。
// 电影库必须返回电影,而不是因为请求里带了 Series 就整行判空。
func TestEmbyItemsMixedTypesKeepsMoviesInMovieLibrary(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
t.Fatalf("migrate: %v", err)
}
libID := seedEmbyLibrary(t, svc, "movie")
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
Base: model.Base{ID: "movie-1"},
LibraryID: libID,
Title: "Movie One",
Path: "/media/movie/one.mp4",
Year: 2024,
ReleaseDate: "2024-01-01",
}).Error; err != nil {
t.Fatalf("create movie: %v", err)
}
path := "/emby/Items?ParentId=" + libID +
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("expected the movie to be returned, got %d items: %s", len(items), w.Body.String())
}
if items[0]["Id"] != "movie-1" || items[0]["Type"] != "Movie" {
t.Fatalf("unexpected item payload: %#v", items[0])
}
}
// 同一组混合类型在剧集库上仍要折叠成剧集卡片,修复不能把这条路径弄回单集。
func TestEmbyItemsMixedTypesKeepsSeriesCardsInTvLibrary(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
t.Fatalf("migrate: %v", err)
}
libID := seedEmbyLibrary(t, svc, "tv")
for _, ep := range []struct {
id string
num int
path string
}{
{id: "ep-1", num: 1, path: "/media/tv/show/S01E01.mkv"},
{id: "ep-2", num: 2, path: "/media/tv/show/S01E02.mkv"},
} {
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
Base: model.Base{ID: ep.id},
LibraryID: libID,
SeriesID: "series-1",
Title: "Show",
Path: ep.path,
SeasonNum: 1,
EpisodeNum: ep.num,
Year: 2024,
}).Error; err != nil {
t.Fatalf("create episode: %v", err)
}
}
path := "/emby/Items?ParentId=" + libID +
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("expected one series card, got %d items: %s", len(items), w.Body.String())
}
if items[0]["Type"] != "Series" {
t.Fatalf("expected Series card, got %#v", items[0])
}
}
// Emby 的 SpecialFeatures 响应是 BaseItemDto[],客户端按 List 强转;
// 返回 {"Items":[]} 会直接抛 type cast 异常。
func TestEmbySpecialFeaturesReturnsBareArray(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Items/movie-1/SpecialFeatures", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
if trimmed := bytes.TrimSpace(w.Body.Bytes()); len(trimmed) == 0 || trimmed[0] != '[' {
t.Fatalf("SpecialFeatures must be a JSON array, got %s", w.Body.String())
}
var items []any
if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
t.Fatalf("decode array: %v body=%s", err, w.Body.String())
}
if len(items) != 0 {
t.Fatalf("expected empty array, got %d entries", len(items))
}
}
+19
View File
@@ -50,6 +50,25 @@ func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag s
embyServePlaceholderImage(c)
return
}
// 媒体库主图:按 Emby 的做法,用库内几张成员海报拼一张封面(folder
// image)。没有自定义封面时才生成;自定义封面继续走下面的代理路径。
if svc.ImageProxy != nil && strings.EqualFold(strings.TrimSpace(imageType), "Primary") {
if sources, lib := service.LibraryCoverSourcesForLibrary(ctx, svc.Repo, id); lib != nil &&
strings.TrimSpace(lib.CoverURL) == "" && len(sources) > 0 {
width, height := libraryCoverRequestSize(c)
if data, err := svc.ImageProxy.LibraryCoverImage(ctx, id, sources, width, height); err == nil && len(data) > 0 {
c.Header("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
c.Header("Content-Type", "image/jpeg")
c.Header("Content-Length", strconv.Itoa(len(data)))
if c.Request.Method == http.MethodHead {
c.Status(http.StatusOK)
return
}
c.Data(http.StatusOK, "image/jpeg", data)
return
}
}
}
// PersonImageURL handles TMDb/remote people first and falls back to regular
// media/library artwork, so the official /Items/{personId}/Images route
// works for synthetic person IDs as well as normal item IDs.
+6
View File
@@ -86,6 +86,12 @@ func embySegmentItems(m *model.Media, rows []model.MediaSegment, want map[string
}
endMs := row.EndMs
if endMs <= 0 {
// 只有片尾类允许「延续到片尾」。片头/回顾的结束点缺失意味着这段区间
// 本身不可信(数据源用 0 表示「这段不存在」),补成片长会让客户端把
// 整集标成片头,所以直接丢弃。
if row.Kind != model.SegmentKindCredits && row.Kind != model.SegmentKindPreview {
continue
}
if durationMs <= 0 {
continue
}
+4 -2
View File
@@ -212,9 +212,11 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Items/:id/Similar", embySimilarHandler(svc))
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
// SpecialFeatures 在 Emby 官方 schema 里是 BaseItemDto[](裸数组),
// 返回 {"Items":[]} 会让按 List 强转的客户端抛 type cast 异常。
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
auth.GET("/Users/:userId/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
auth.GET("/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc))
}
+2 -2
View File
@@ -55,9 +55,9 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/items/:id/similar", embySimilarHandler(svc))
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
auth.GET("/users/:userId/items/:id/intros", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
auth.GET("/items/:id/intros", embyEmptyItemsHandler(svc))
}
+100
View File
@@ -0,0 +1,100 @@
package handler
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// libraryCoverHandler 输出媒体库封面图(Emby 风格的成员海报拼图)。
//
// 之前 Web 端是拿两张成员海报在前端用 CSS 拼格子,Emby 客户端则直接拿一张
// 评分最高的海报,结果同一个库在不同客户端长得完全不一样。现在服务端生成
// 一张 16:9 的拼图(Emby 的 folder image 做法),Emby 客户端、Web 和 App
// 都指向这一份,外观统一。
func libraryCoverHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" || svc == nil || svc.ImageProxy == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "cover unavailable"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 25*time.Second)
defer cancel()
sources, lib := service.LibraryCoverSourcesForLibrary(ctx, svc.Repo, id)
if lib == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "library not found"})
return
}
if strings.TrimSpace(lib.CoverURL) != "" {
// 管理员自定义封面优先,保持既有行为(代理该地址)。
if err := svc.ImageProxy.Serve(ctx, c.Writer, c.Request, lib.CoverURL); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "custom cover unavailable"})
}
return
}
if len(sources) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no library items with artwork"})
return
}
width, height := libraryCoverRequestSize(c)
data, err := svc.ImageProxy.LibraryCoverImage(ctx, id, sources, width, height)
if err != nil || len(data) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "cover generation failed"})
return
}
writeLibraryCoverImage(c, data, http.StatusOK)
}
}
// libraryCoverRequestSize 解析请求的封面尺寸:Web 用 w/h,Emby 用
// maxWidth/maxHeight,两种都兼容。服务端统一输出 16:9 画布(与该库在 Emby
// 里上报的 PrimaryImageAspectRatio 一致),客户端传的尺寸只当作「不超过」的
// 边界使用。
func libraryCoverRequestSize(c *gin.Context) (int, int) {
return firstPositiveQueryIntParam(c, "maxWidth", "maxwidth", "width", "w"),
firstPositiveQueryIntParam(c, "maxHeight", "maxheight", "height", "h")
}
func firstPositiveQueryIntParam(c *gin.Context, names ...string) int {
for _, name := range names {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
continue
}
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
return n
}
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
return int(f)
}
}
return 0
}
func writeLibraryCoverImage(c *gin.Context, data []byte, status int) {
sum := sha256.Sum256(data)
etag := `"` + hex.EncodeToString(sum[:8]) + `"`
c.Header("ETag", etag)
c.Header("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
if match := strings.TrimSpace(c.GetHeader("If-None-Match")); match != "" && match == etag {
c.Status(http.StatusNotModified)
return
}
c.Header("Content-Type", "image/jpeg")
c.Header("Content-Length", strconv.Itoa(len(data)))
if c.Request.Method == http.MethodHead {
c.Status(status)
return
}
c.Data(status, "image/jpeg", data)
}
@@ -0,0 +1,118 @@
package handler
import (
"bytes"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func coverTestToken(t *testing.T, secret, userID string) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, middleware.Claims{
UserID: userID,
Role: "admin",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: userID,
},
})
signed, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return signed
}
// TestLibraryCoverRouteServesImageThroughFullRouter 走完整的 handler.Register
// 路由表请求封面:既验证路由挂载正确,也验证鉴权链(AuthRequired +
// activeUserRequired)不会把它挡成 404。
func TestLibraryCoverRouteServesImageThroughFullRouter(t *testing.T) {
gin.SetMode(gin.TestMode)
const secret = "test-secret"
poster := coverTestJPEG(t, color.RGBA{R: 200, G: 120, B: 60, A: 255})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(poster)
}))
defer upstream.Close()
parsed, _ := url.Parse(upstream.URL)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
user := model.User{Base: model.Base{ID: "user-1"}, Username: "viewer", IsActive: true}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 0; i < 4; i++ {
row := model.Media{
Base: model.Base{ID: "m" + string(rune('a'+i))},
LibraryID: lib.ID,
Title: "Movie " + string(rune('A'+i)),
Path: "/media/movies/m" + string(rune('a'+i)) + ".mkv",
PosterURL: upstream.URL + "/p" + string(rune('1'+i)) + ".jpg",
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
cfg := &config.Config{
Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")},
Secrets: config.SecretsConfig{JWTSecret: secret},
}
proxy := service.NewImageProxy(cfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
svc := &service.Container{Repo: repos, ImageProxy: proxy}
engine := gin.New()
Register(engine, cfg, zap.NewNop(), svc)
token := coverTestToken(t, secret, user.ID)
req := httptest.NewRequest(http.MethodGet, "/api/libraries/"+lib.ID+"/cover?w=480&h=270&api_key="+token, nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", ct)
}
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("decode cover: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
t.Fatalf("cover size = %v, want 480x270", img.Bounds())
}
}
+228
View File
@@ -0,0 +1,228 @@
package handler
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func coverTestJPEG(t *testing.T, c color.RGBA) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 160, 240))
for y := 0; y < 240; y++ {
for x := 0; x < 160; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}
// newLibraryCoverTestRouter 构造一个带真实图片代理的路由:成员海报来自
// 本地 httptest 服务,走的是与生产一致的 ImageProxy 抓取路径。
func newLibraryCoverTestRouter(t *testing.T) (*gin.Engine, *repository.Container, *httptest.Server) {
t.Helper()
gin.SetMode(gin.TestMode)
poster := coverTestJPEG(t, color.RGBA{R: 200, G: 120, B: 60, A: 255})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(poster)
}))
t.Cleanup(upstream.Close)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
parsed, err := url.Parse(upstream.URL)
if err != nil {
t.Fatalf("parse upstream: %v", err)
}
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
proxy := service.NewImageProxy(cfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
svc := &service.Container{Repo: repos, ImageProxy: proxy}
router := gin.New()
router.GET("/libraries/:id/cover", libraryCoverHandler(svc))
router.HEAD("/libraries/:id/cover", libraryCoverHandler(svc))
return router, repos, upstream
}
func seedCoverLibrary(t *testing.T, repos *repository.Container, posterBase string, coverURL string) model.Library {
t.Helper()
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true, CoverURL: coverURL}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 0; i < 4; i++ {
row := model.Media{
Base: model.Base{ID: "m" + string(rune('a'+i))},
LibraryID: lib.ID,
Title: "Movie " + string(rune('A'+i)),
Path: "/media/movies/m" + string(rune('a'+i)) + ".mkv",
PosterURL: posterBase + "/p" + string(rune('1'+i)) + ".jpg",
}
if err := repos.DB.Create(&row).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
return lib
}
// TestLibraryCoverHandlerServesGeneratedCollage 覆盖 Web/App 的封面接口:
// 返回的是服务端拼好的 16:9 图片,而不是单张成员海报。
func TestLibraryCoverHandlerServesGeneratedCollage(t *testing.T) {
router, repos, upstream := newLibraryCoverTestRouter(t)
lib := seedCoverLibrary(t, repos, upstream.URL, "")
req := httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover?w=480&h=270", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", ct)
}
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("decode cover: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
t.Fatalf("cover size = %v, want 480x270", img.Bounds())
}
if etag := w.Header().Get("ETag"); etag == "" {
t.Fatal("expected an ETag so clients can revalidate")
}
// 条件请求应拿到 304。
req = httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover?w=480&h=270", nil)
req.Header.Set("If-None-Match", w.Header().Get("ETag"))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotModified {
t.Fatalf("conditional request status = %d, want 304", rec.Code)
}
}
func TestLibraryCoverHandlerUnknownLibraryReturns404(t *testing.T) {
router, _, _ := newLibraryCoverTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/libraries/missing/cover", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
// TestLibraryCoverHandlerServesCustomCover 管理员填了 cover_url 时必须继续
// 用自定义封面,不能在不知不觉中换成拼图。
func TestLibraryCoverHandlerServesCustomCover(t *testing.T) {
router, repos, upstream := newLibraryCoverTestRouter(t)
lib := seedCoverLibrary(t, repos, upstream.URL, upstream.URL+"/custom.jpg")
req := httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if _, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes())); err != nil {
t.Fatalf("custom cover should be an image: %v", err)
}
}
// TestEmbyLibraryPrimaryUsesGeneratedCollage 保证 Emby 客户端请求库主图时
// 拿到的也是拼图(与 Web/App 同一份生成逻辑)。
func TestEmbyLibraryPrimaryUsesGeneratedCollage(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatalf("migrate: %v", err)
}
poster := coverTestJPEG(t, color.RGBA{G: 180, R: 90, B: 60, A: 255})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(poster)
}))
defer upstream.Close()
parsed, _ := url.Parse(upstream.URL)
repos := repository.New(db)
lib := model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 0; i < 4; i++ {
row := model.Media{
Base: model.Base{ID: "e" + string(rune('a'+i))},
LibraryID: lib.ID,
Title: "Show " + string(rune('A'+i)),
Path: "/media/tv/show" + string(rune('a'+i)) + "/S01E01.mkv",
SeasonNum: 1, EpisodeNum: 1,
PosterURL: upstream.URL + "/p" + string(rune('1'+i)) + ".jpg",
}
if err := repos.DB.Create(&row).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
proxy := service.NewImageProxy(cfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
ImageProxy: proxy,
}
router := gin.New()
registerEmbyRoutes(router, "test-secret", svc)
req := httptest.NewRequest(http.MethodGet, "/Items/"+lib.ID+"/Images/Primary?maxWidth=400&maxHeight=300", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("decode emby library cover: %v", err)
}
// 请求的是 400×300 的盒子,拼图按 16:9 装进去 → 400×225。
if img.Bounds().Dx() != 400 || img.Bounds().Dy() != 225 {
t.Fatalf("cover size = %v, want 400x225 (16:9 inside the requested box)", img.Bounds())
}
}
+27 -6
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@@ -40,6 +41,25 @@ type webLibraryPayload struct {
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCardView `json:"cards,omitempty"`
// CoverImageURL 是客户端可直接使用的「有效封面」:管理员自定义的
// cover_url 优先,否则指向服务端生成的成员海报拼图接口。Web / App 都
// 读这一个字段,避免各自用卡片在前端拼格子、长得还不一样。
CoverImageURL string `json:"cover_image_url,omitempty"`
}
// effectiveLibraryCoverURL 计算库封面的有效地址。远程挂载库的封面由上游
// Emby 决定,这里返回空串,调用方继续用卡片拼图兜底。
func effectiveLibraryCoverURL(lib model.Library, isRemote bool) string {
if isRemote {
return ""
}
if raw := strings.TrimSpace(lib.CoverURL); raw != "" {
return raw
}
if strings.TrimSpace(lib.ID) == "" {
return ""
}
return "/api/libraries/" + url.PathEscape(lib.ID) + "/cover"
}
// remoteLibraryPreviewBudget 限制 /api/libraries?with_preview=1 里远程 Emby
@@ -124,9 +144,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
for _, p := range previews {
out = append(out, webLibraryPayload{
Library: p.Library,
Total: p.Total,
Cards: service.NewSeriesCardViews(p.Cards),
Library: p.Library,
Total: p.Total,
Cards: service.NewSeriesCardViews(p.Cards),
CoverImageURL: effectiveLibraryCoverURL(p.Library, false),
})
}
} else {
@@ -145,7 +166,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
if counts != nil {
total = counts[l.ID]
}
out = append(out, webLibraryPayload{Library: l, Total: total})
out = append(out, webLibraryPayload{Library: l, Total: total, CoverImageURL: effectiveLibraryCoverURL(l, false)})
}
}
// 精确指定目标库时,如果目标全是本地库,就不必枚举远程挂载。
@@ -272,9 +293,9 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0]})
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0], CoverImageURL: effectiveLibraryCoverURL(libs[0], false)})
} else {
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib})
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib, CoverImageURL: effectiveLibraryCoverURL(*lib, false)})
}
}
}
+72 -1
View File
@@ -1,13 +1,18 @@
// Package handler — 片头/片尾片段接口。
//
// GET /playback/:id/segments
// GET /playback/:id/segments
// PUT /playback/:id/segments {kind, position_ms, clear}
//
// 单独开一个接口而不是塞进 /media/:id/playback,有两个原因:一是抓取外部数据
// 可能要几秒,不能拖慢决定能否起播的那个请求;二是片段与播放来源(本地 / 云盘 /
// 远程 Emby)无关,独立出来对所有媒体一致。
//
// 写入接口只处理「手动标记」:用户播放时把当前位置标成片头结束 / 片尾开始。
// 外部数据永远由提供方刷新,不接受客户端写入。
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
@@ -42,6 +47,72 @@ func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
}
}
// playbackSegmentsMarkRequest 是手动标记的入参。
type playbackSegmentsMarkRequest struct {
// Kind 只接受 intro / credits。
Kind string `json:"kind" binding:"required"`
// PositionMs 是当前播放位置:标片头时表示片头结束点,标片尾时表示片尾开始点。
PositionMs int64 `json:"position_ms"`
// Clear 为 true 时删除该类型的标记。
Clear bool `json:"clear"`
}
// playbackSegmentsMarkHandler 写入或清除一条手动标记,返回更新后的完整片段列表。
//
// 返回整份列表而不是只回 204:播放器需要立刻反映合并后的结果(手动标记优先级
// 最高),避免自己再发一次 GET 造成竞态。
func playbackSegmentsMarkHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Segments == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "segments unavailable"})
return
}
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
// 远程 Emby 挂载是上游库的投影,本地标记没有可落地的地方。
if service.IsEmbyRemoteID(m.ID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "remote media cannot be marked"})
return
}
var req playbackSegmentsMarkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
err = svc.Segments.SetManualMark(c.Request.Context(), m, service.ManualMarkRequest{
Kind: req.Kind,
PositionMs: req.PositionMs,
Clear: req.Clear,
})
if errors.Is(err, service.ErrInvalidManualMark) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid skip mark"})
return
}
if err != nil {
if svc.Log != nil {
svc.Log.Warn("save manual skip mark failed",
zap.String("media_id", m.ID), zap.Error(err))
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
return
}
rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m)
if listErr != nil {
if svc.Log != nil {
svc.Log.Debug("list media segments after mark failed",
zap.String("media_id", m.ID), zap.Error(listErr))
}
}
c.JSON(http.StatusOK, gin.H{
"segments": service.ToSegmentViews(rows),
"auto_skip": resolveAutoSkipFlag(c, svc),
})
}
}
// resolveAutoSkipFlag reads the「自动跳过片头」switch off whichever profile is
// currently in effect. It reuses selectedPlayProfile so the server agrees with
// the UI about which profile is active (explicit header first, then the user's
+406
View File
@@ -0,0 +1,406 @@
package handler
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 音轨「区间直通」的行为契约。
//
// 背景(真实故障):播放器(media3)取一条 m4a 会开两次请求——先整段打开嗅探
// 容器,再带 Range 从目标位置重开。旧实现只允许「无 Range + 无分片」的请求
// 边下边发,于是带 Range 的那次(真正的起播请求)必须把整章下完再按区间下发。
// 线上日志:每章固定 200(探针,被播放器掐断)+ 206(整章下完才吐第一个字节),
// 42 章里「媒体已装载 → 实际起播」平均 4.79 秒,最坏 35 秒。
//
// 现在的契约:
// - 带 Range 的冷缓存请求 → 上游一到字节就往下发,不等整章;
// - 分片已有的前缀从本地发,上游只续下剩余字节;
// - 整章落满时把分片提升为正式缓存,之后走本地;
// - 上游不认 Range 时也要给出正确的 206(跳过前缀),且不写带洞的分片;
// - 拿不到写锁时降级成纯透传,绝不排队等前一个下载。
// rangeUpstream 支持 Range 的上游:/content 给出音轨地址,/media 回音频本体。
type rangeUpstream struct {
*httptest.Server
payload []byte
honorRange bool
// firstChunk > 0 且 release 非 nil 时:先写这么多字节再等放行,
// 用来证明「客户端不必等整章下完」。
firstChunk int
release chan struct{}
mu sync.Mutex
ranges []string
hits int
}
func newRangeUpstream(t *testing.T, payload []byte, honorRange bool) *rangeUpstream {
t.Helper()
u := &rangeUpstream{payload: payload, honorRange: honorRange}
u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/book/1"):
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body><h1>区间之书</h1></body></html>`))
case r.URL.Path == "/content":
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media"}}`, u.URL)))
case r.URL.Path == "/media":
u.serveMedia(w, r)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(u.Close)
return u
}
func (u *rangeUpstream) serveMedia(w http.ResponseWriter, r *http.Request) {
u.mu.Lock()
u.hits++
u.ranges = append(u.ranges, r.Header.Get("Range"))
u.mu.Unlock()
total := len(u.payload)
start, end := 0, total-1
partial := false
if u.honorRange {
if s, e, ok := parseReaderAudioRange(r.Header.Get("Range")); ok {
start = int(s)
if e >= 0 && int(e) < end {
end = int(e)
}
partial = true
}
}
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Accept-Ranges", "bytes")
if start >= total {
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
return
}
body := u.payload[start : end+1]
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
if partial {
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
w.WriteHeader(http.StatusPartialContent)
} else {
w.WriteHeader(http.StatusOK)
}
if u.release != nil && u.firstChunk > 0 && len(body) > u.firstChunk {
_, _ = w.Write(body[:u.firstChunk])
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
select {
case <-u.release:
case <-r.Context().Done():
return
}
body = body[u.firstChunk:]
}
_, _ = w.Write(body)
}
func (u *rangeUpstream) rangeHeaders() []string {
u.mu.Lock()
defer u.mu.Unlock()
return append([]string(nil), u.ranges...)
}
func (u *rangeUpstream) requestCount() int {
u.mu.Lock()
defer u.mu.Unlock()
return u.hits
}
// prepareRangeBook 建一本「书源直连上游」的音频书,并取回该音轨的签名代理路径。
func prepareRangeBook(t *testing.T, upstream string) (*service.Container, *model.ReaderBook, string) {
t.Helper()
svc := newReaderHandlerContainer(t)
svc.Cfg.Cache.CacheDir = t.TempDir()
srcJSON := fmt.Sprintf(`{
"bookSourceName": "区间直通测试",
"bookSourceType": 1,
"bookSourceUrl": %q,
"ruleContent": {"content": "$.data.url"}
}`, upstream)
if _, err := svc.Reader.ImportSources(t.Context(), "u1", srcJSON); err != nil {
t.Fatalf("导入书源失败: %v", err)
}
srcs, err := svc.Reader.ListSources(t.Context(), "u1")
if err != nil || len(srcs) == 0 {
t.Fatalf("读不到书源: %v", err)
}
book, err := svc.Reader.AddBook(t.Context(), "u1", reader.SearchOrigin{
SourceID: srcs[0].ID,
Origin: upstream,
OriginName: "区间直通测试",
OriginType: 1,
BookURL: upstream + "/book/1",
}, "区间之书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
if err := svc.Reader.SaveChapters(t.Context(), book.ID, []reader.ChapterInput{
{Index: 0, Title: "第一章", URL: upstream + "/content"},
}); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
out, err := svc.Reader.GetContentForBook(t.Context(), "u1", book.ID, 0, reader.ContentOptions{})
if err != nil {
t.Fatalf("取章节失败: %v", err)
}
if len(out.Tracks) != 1 {
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
}
return svc, book, out.Tracks[0]
}
func audioPayload(n int) []byte {
payload := make([]byte, n)
for i := range payload {
payload[i] = byte(i % 251)
}
return payload
}
// waitCached 等待分片被提升为正式缓存(提升发生在响应收尾,与客户端读完成之间有微小竞态)。
func waitCached(t *testing.T, svc *service.Container, bookID, mediaURL string) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if svc.Reader.HasCachedRemoteAudio(bookID, mediaURL) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("整章下完后应提升为正式缓存")
}
// TestReaderAudioRangeStreamsWithoutWaitingForWholeChapter 冷缓存 + Range:
// 上游只吐了第一段,客户端就必须已经拿到字节。
func TestReaderAudioRangeStreamsWithoutWaitingForWholeChapter(t *testing.T) {
payload := audioPayload(512 << 10)
upstream := newRangeUpstream(t, payload, true)
upstream.firstChunk = 64 << 10
upstream.release = make(chan struct{})
svc, book, track := prepareRangeBook(t, upstream.URL)
mediaURL := upstream.URL + "/media"
srv := httptest.NewServer(mediaRouterForTest(svc))
defer srv.Close()
req, err := http.NewRequest(http.MethodGet, srv.URL+track, nil)
if err != nil {
t.Fatalf("构造请求失败: %v", err)
}
req.Header.Set("Range", "bytes=0-")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("状态码 = %d,期望 206", resp.StatusCode)
}
if got := resp.Header.Get("Content-Range"); got != fmt.Sprintf("bytes 0-%d/%d", len(payload)-1, len(payload)) {
t.Fatalf("Content-Range = %q", got)
}
if got := resp.Header.Get("Content-Length"); got != strconv.Itoa(len(payload)) {
t.Fatalf("Content-Length = %q,期望 %d", got, len(payload))
}
// 上游此时还被 release 卡着:能在超时内读到第一段就说明没有等整章。
head := make([]byte, upstream.firstChunk)
readDone := make(chan error, 1)
go func() {
_, readErr := io.ReadFull(resp.Body, head)
readDone <- readErr
}()
select {
case readErr := <-readDone:
if readErr != nil {
t.Fatalf("读首段失败: %v", readErr)
}
case <-time.After(3 * time.Second):
t.Fatalf("上游还没给完就卡住了:客户端没能在整章下完前拿到第一个字节")
}
if !bytes.Equal(head, payload[:upstream.firstChunk]) {
t.Fatalf("首段字节不一致")
}
close(upstream.release)
rest, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("读剩余字节失败: %v", err)
}
if got := append(head, rest...); !bytes.Equal(got, payload) {
t.Fatalf("整体字节不一致: got %d bytes", len(got))
}
if got := upstream.rangeHeaders(); len(got) != 1 || got[0] != "bytes=0-" {
t.Fatalf("上游收到的 Range = %v,期望且仅一次 bytes=0-", got)
}
waitCached(t, svc, book.ID, mediaURL)
}
// TestReaderAudioRangeResumesFromPartialFragment 分片里已有前缀时,前缀从本地发,
// 上游只续下剩余字节;整章落满后提升为正式缓存。
func TestReaderAudioRangeResumesFromPartialFragment(t *testing.T) {
payload := audioPayload(256 << 10)
upstream := newRangeUpstream(t, payload, true)
svc, book, track := prepareRangeBook(t, upstream.URL)
mediaURL := upstream.URL + "/media"
// 模拟「播放器嗅探完就断开」留下的分片:前 96KB 已经在盘上。
const partLen = 96 << 10
cachePath := svc.Reader.RemoteAudioCachePath(book.ID, mediaURL)
if cachePath == "" {
t.Fatalf("缓存路径为空")
}
if mkErr := os.MkdirAll(cachePath[:strings.LastIndex(cachePath, string(os.PathSeparator))], 0o750); mkErr != nil {
t.Fatalf("建缓存目录失败: %v", mkErr)
}
if writeErr := os.WriteFile(cachePath+".part", payload[:partLen], 0o640); writeErr != nil {
t.Fatalf("写分片失败: %v", writeErr)
}
code, body := getReaderMediaWithRange(t, mediaRouterForTest(svc), track, "bytes=0-")
if code != http.StatusPartialContent {
t.Fatalf("状态码 = %d,期望 206 body=%.120s", code, body)
}
if !bytes.Equal([]byte(body), payload) {
t.Fatalf("拼接后的字节不一致: got %d bytes want %d", len(body), len(payload))
}
headers := upstream.rangeHeaders()
if len(headers) != 1 || headers[0] != fmt.Sprintf("bytes=%d-", partLen) {
t.Fatalf("上游收到的 Range = %v,期望从分片长度续下 bytes=%d-", headers, partLen)
}
waitCached(t, svc, book.ID, mediaURL)
// 提升之后同一首歌不再回源。
before := upstream.requestCount()
code, body = getReaderMedia(t, mediaRouterForTest(svc), track)
if code != http.StatusOK || !bytes.Equal([]byte(body), payload) {
t.Fatalf("命中缓存失败 status=%d len=%d", code, len(body))
}
if after := upstream.requestCount(); after != before {
t.Fatalf("命中缓存后不应再回源,多了 %d 次", after-before)
}
}
// TestReaderAudioRangeHandlesUpstreamThatIgnoresRange 上游不认 Range(回 200 整段)时,
// 仍要按客户端要的起点给出正确的 206,并且不写带洞的分片。
func TestReaderAudioRangeHandlesUpstreamThatIgnoresRange(t *testing.T) {
payload := audioPayload(300 << 10)
upstream := newRangeUpstream(t, payload, false)
svc, book, track := prepareRangeBook(t, upstream.URL)
mediaURL := upstream.URL + "/media"
const start = 1000
code, body := getReaderMediaWithRange(t, mediaRouterForTest(svc), track, fmt.Sprintf("bytes=%d-", start))
if code != http.StatusPartialContent {
t.Fatalf("状态码 = %d,期望 206 body=%.120s", code, body)
}
if !bytes.Equal([]byte(body), payload[start:]) {
t.Fatalf("区间字节不一致: got %d want %d", len(body), len(payload)-start)
}
// 起点不在分片内 → 不能落盘(否则分片开头会是空洞)。
if svc.Reader.HasPartialRemoteAudio(book.ID, mediaURL) {
t.Fatalf("起点靠后的区间请求不应生成分片")
}
if svc.Reader.HasCachedRemoteAudio(book.ID, mediaURL) {
t.Fatalf("只下了一段不应生成完整缓存")
}
}
// TestReaderAudioRangeFallsBackToPassthroughWhenWriterBusy 拿不到写锁时不排队:
// 仍然立刻把区间发出去,只是这一轮不落盘。
func TestReaderAudioRangeFallsBackToPassthroughWhenWriterBusy(t *testing.T) {
payload := audioPayload(128 << 10)
upstream := newRangeUpstream(t, payload, true)
svc, book, track := prepareRangeBook(t, upstream.URL)
mediaURL := upstream.URL + "/media"
cacheKey := svc.Reader.RemoteAudioCachePath(book.ID, mediaURL)
if cacheKey == "" {
t.Fatalf("缓存路径为空")
}
unlock := readerAudioStreamLocks.Lock(cacheKey)
defer unlock()
done := make(chan struct{})
var code int
var body string
go func() {
code, body = getReaderMediaWithRange(t, mediaRouterForTest(svc), track, "bytes=0-")
close(done)
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatalf("写锁被占用时不该让请求排队等待")
}
if code != http.StatusPartialContent {
t.Fatalf("状态码 = %d,期望 206 body=%.120s", code, body)
}
if !bytes.Equal([]byte(body), payload) {
t.Fatalf("区间字节不一致: got %d want %d", len(body), len(payload))
}
if svc.Reader.HasCachedRemoteAudio(book.ID, mediaURL) {
t.Fatalf("降级透传不应写缓存")
}
}
func TestParseReaderAudioRangeCases(t *testing.T) {
cases := []struct {
in string
start int64
end int64
want bool
}{
{"bytes=0-", 0, -1, true},
{"bytes=100-199", 100, 199, true},
{"Bytes=1024-", 1024, -1, true},
{" bytes=5-5 ", 5, 5, true},
{"bytes=-500", 0, 0, false},
{"bytes=0-99,200-299", 0, 0, false},
{"items=0-", 0, 0, false},
{"bytes=abc-", 0, 0, false},
{"bytes=200-100", 0, 0, false},
{"", 0, 0, false},
}
for _, c := range cases {
start, end, ok := parseReaderAudioRange(c.in)
if ok != c.want || start != c.start || end != c.end {
t.Errorf("parseReaderAudioRange(%q) = (%d,%d,%v),期望 (%d,%d,%v)",
c.in, start, end, ok, c.start, c.end, c.want)
}
}
}
// getReaderMediaWithRange 发一次带 Range 头的媒体代理请求。
func getReaderMediaWithRange(t *testing.T, router *gin.Engine, signedPath, rangeHeader string) (int, string) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signedPath, nil)
req.Header.Set("Range", rangeHeader)
router.ServeHTTP(w, req)
return w.Code, w.Body.String()
}
@@ -0,0 +1,38 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service/reader"
)
func TestReaderContentOptions(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
query string
want reader.ContentOptions
}{
{"", reader.ContentOptions{}},
{"?media=direct", reader.ContentOptions{MediaDirect: true}},
{"?media=proxy", reader.ContentOptions{ForceProxy: true}},
{"?audio=native", reader.ContentOptions{NativeAudio: true}},
{"?audio=native&media=direct", reader.ContentOptions{NativeAudio: true, MediaDirect: true}},
{"?audio=native&media=proxy", reader.ContentOptions{NativeAudio: true, ForceProxy: true}},
{"?audio=%20NaTiVe%20&media=%20DiReCt%20", reader.ContentOptions{NativeAudio: true, MediaDirect: true}},
{"?audio=native&media=unknown", reader.ContentOptions{NativeAudio: true}},
{"?audio=browser&media=proxy", reader.ContentOptions{ForceProxy: true}},
{"?audio=unknown&media=direct", reader.ContentOptions{MediaDirect: true}},
} {
t.Run(tc.query, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/api/reader/books/book/content"+tc.query, nil)
if got := readerContentOptions(c); got != tc.want {
t.Fatalf("options = %+v, want %+v", got, tc.want)
}
})
}
}
+764
View File
@@ -0,0 +1,764 @@
package handler
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 媒体代理在「上游签名票被拒」时的行为契约。
//
// 背景(真实故障):聆阅·懒人听书源的音频直链是第三方中转现签的腾讯 CDN 票,
// 票被 CDN 判掉时上游回的是**空的 401**(无 Content-Type、无 body)。旧行为把它
// 原样透传给播放器,播放器重试的还是同一个 URL,于是 App 日志里连续 4~8 个
// ERROR_CODE_IO_BAD_HTTP_STATUS (2004),表现为「这本书怎么都播不了」。
//
// 现在的契约:
// - 能换票 → 用新票重拉,直接返回媒体本体(第一个请求就被治好);
// - 换不到 → 502 + 明确文案,而不是把 401 丢给播放器;
// - 非音轨地址(漫画图片防盗链等)→ 维持原样透传,不改变既有行为。
func TestReaderMediaPayloadRejected(t *testing.T) {
cases := []struct {
name string
head string
contentType string
want bool
}{
{"HTML 错误页", "<!DOCTYPE html><html><body>403</body></html>", "text/html; charset=utf-8", true},
{"JSON 报错", `{"code":403,"message":"expired"}`, "application/json", true},
{"数组形态报错", `[{"error":"bad ticket"}]`, "application/json; charset=utf-8", true},
{"纯文本报错", "ticket expired", "text/plain", false},
{"正常 m4a", "\x00\x00\x00\x20ftypmp42", "audio/mp4", false},
{"正常 mp3", "ID3\x04\x00", "audio/mpeg", false},
{"正常图片", "\xff\xd8\xff\xe0", "image/jpeg", false},
{"播放列表", "#EXTM3U\n#EXTINF:10,", "application/vnd.apple.mpegurl", false},
{"空体", "", "text/html", false},
{"HTML 类型但二进制体", "\x00\x00\x00\x20ftypmp42", "text/html", false},
}
for _, c := range cases {
got := readerMediaPayloadRejected([]byte(c.head), c.contentType)
if got != c.want {
t.Errorf("%s: readerMediaPayloadRejected(%q, %q) = %v,期望 %v",
c.name, c.head, c.contentType, got, c.want)
}
}
}
// TestReaderMediaProxyHealsStaleTicket 票被上游拒绝时代理自己换票重拉,
// 第一个请求就返回媒体本体(不必等播放器重试)。
func TestReaderMediaProxyHealsStaleTicket(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
// 让中转再签一张新票:章节缓存里那张就此变成「旧票」。
srv.rotate()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK {
t.Fatalf("换票重试后 status=%d body=%s", code, body)
}
if !strings.Contains(body, "fake-audio-bytes") {
t.Fatalf("响应体不是媒体: %q", body)
}
if got := srv.contentHits(); got < 2 {
t.Fatalf("应当回源重取章节,实际请求 %d 次", got)
}
}
// TestReaderMediaProxyFailsClearlyWhenRefreshImpossible 换不到票时给 502 + 明确文案,
// 而不是把 401 透传给播放器(那样播放器只会对着必然失败的地址反复重试)。
func TestReaderMediaProxyFailsClearlyWhenRefreshImpossible(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
srv.rotate()
srv.stopIssuing()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("换票失败应当返回 502,实际 %d body=%s", code, body)
}
if !strings.Contains(body, "签名失效") {
t.Fatalf("错误文案应说明是音源签名失效: %s", body)
}
}
// TestReaderMediaProxyStopsResendingRejectedTicket 上游成批拒绝时(连新签的票
// 也是空 401),不能在播放器那一次请求里把同一张被拒的票打上十几遍:连续两轮
// 换票都是同一个状态码就立刻失败,把明确文案交出去。
func TestReaderMediaProxyStopsResendingRejectedTicket(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
srv.denyAlways()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("上游持续拒绝应返回 502,实际 %d body=%s", code, body)
}
if !strings.Contains(body, "401") {
t.Fatalf("错误文案应带上上游状态码: %s", body)
}
// 每轮 fetchReaderMedia = 首拉(401) + 换票后重拉(401),两轮即收手。
if got := srv.mediaRequests(); got > 4 {
t.Fatalf("无进展的拒绝应当两轮就收手,实际上游请求 %d 次", got)
}
}
// TestReaderAudioCacheResumesTruncatedUpstream 上游中途 RST 后用 Range 续传:
// 播放器最终拿到的仍是完整的一章,而不是从头重下。
func TestReaderAudioCacheResumesTruncatedUpstream(t *testing.T) {
const bodyBytes = 300 << 10
payload := bytes.Repeat([]byte("m4a!"), bodyBytes/4)
var ranges []string
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
ranges = append(ranges, r.Header.Get("Range"))
count := len(ranges)
mu.Unlock()
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Accept-Ranges", "bytes")
start := 0
if count > 1 {
// 第二次起按 Range 续传。
start = bodyBytes / 2
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, bodyBytes-1, bodyBytes))
w.WriteHeader(http.StatusPartialContent)
} else {
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
}
chunk := payload[start:]
if count == 1 {
// 第一次只写一半就断开。
chunk = payload[:bodyBytes/2]
}
for written := 0; written < len(chunk); written += 8 << 10 {
end := written + 8<<10
if end > len(chunk) {
end = len(chunk)
}
if _, err := w.Write(chunk[written:end]); err != nil {
return
}
}
w.(http.Flusher).Flush()
if count == 1 {
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
}
}))
defer srv.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK {
t.Fatalf("续传后应当成功,实际 status=%d body=%.120s", code, body)
}
if len(body) != bodyBytes {
t.Fatalf("交付字节 = %d,期望 %d", len(body), bodyBytes)
}
mu.Lock()
defer mu.Unlock()
if len(ranges) < 2 || ranges[0] != "" {
t.Fatalf("请求序列 = %q,期望先整段再续传", ranges)
}
if !strings.HasPrefix(ranges[1], "bytes=") {
t.Fatalf("第二轮应当带 Range,实际 %q", ranges[1])
}
}
// TestReaderMediaProxyPassesThroughNonTrackError 漫画图片这类不可换票的地址,
// 上游 4xx 仍按原状态透传(既有行为不变)。
func TestReaderMediaProxyPassesThroughNonTrackError(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
// 书源域名下的图片地址:不在任何音轨缓存里。
imgURL := srv.URL + "/img/forbidden.jpg"
signed := svc.Reader.ProxyURL(book.ID, imgURL)
code, body := getReaderMedia(t, router, signed)
if code == http.StatusBadGateway || code == http.StatusUnauthorized {
t.Fatalf("非音轨地址不该走换票分支,实际 %d body=%s", code, body)
}
if code != http.StatusForbidden {
t.Fatalf("图片 403 应原样透传,实际 %d body=%s", code, body)
}
if got := srv.contentHits(); got != 0 {
t.Fatalf("图片失败不该触发回源取章节,实际 %d 次", got)
}
}
// TestReaderMediaProxyRejectsNonMediaPayload 上游用 200 回一段 HTML/JSON 错误页时,
// 代理直接判失败,不把网页喂给播放器(否则报「格式不支持」)。
func TestReaderMediaProxyRejectsNonMediaPayload(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
srv.serveErrorPage()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("非媒体内容应当返回 502,实际 %d body=%.160s", code, body)
}
if !strings.Contains(body, "非媒体内容") {
t.Fatalf("错误文案应说明上游返回了非媒体内容: %s", body)
}
}
// ─── 上游抖动:连接被 reset 时的重试与截断可见化 ─────────────────────────
// TestReaderMediaProbeRetriesConnectionReset 上游在响应体中途 RST 连接时,
// 代理要退避重试,而不是第一次读失败就回 502。
//
// 真实故障:腾讯 CDN 的跨境边缘在并发/长传输时会直接 RST,同一章重试一次
// 往往就好。以前这里读 body 失败即判死,把一次可恢复的抖动变成「这本书播不了」。
func TestReaderMediaProbeRetriesConnectionReset(t *testing.T) {
var attempts int32
const bodyBytes = 200 << 10
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&attempts, 1)
if n == 1 {
// 第一次:声明长度,写一小段后直接断开连接(模拟 RST/提前 EOF)。
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42"))
w.(http.Flusher).Flush()
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
return
}
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
payload := make([]byte, 8<<10)
for written := 0; written < bodyBytes; written += len(payload) {
if _, err := w.Write(payload); err != nil {
return
}
}
}))
defer srv.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK {
t.Fatalf("连接被 reset 后应当重试成功,实际 status=%d body=%.200s", code, body)
}
if int32(len(body)) != bodyBytes {
t.Fatalf("交付字节 = %d,期望 %d", len(body), bodyBytes)
}
if got := atomic.LoadInt32(&attempts); got < 2 {
t.Fatalf("应当重试过,实际上游请求 %d 次", got)
}
}
// TestReaderMediaAbortsWhenUpstreamTruncates 非音轨的流式透传路径:上游中途
// 断开且重试都失败时,必须让客户端看到「传输不完整」,不能返回一个长度正确
// 却内容缺失的响应。
//
// 以前这里是 `_, _ = io.Copy(...)`,错误被丢掉,客户端拿到一个正常结束的
// 半截文件 —— 播放器只能报「格式不支持」(3003),根因被彻底掩盖。
//
// 音轨走的是整段落盘缓存(见 TestReaderAudioCacheRejectsTruncatedUpstream):
// 拉不完整就重试,全部失败回 502,绝不会把半截音频交给播放器。
func TestReaderMediaAbortsWhenUpstreamTruncates(t *testing.T) {
const declared = 400 << 10
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(declared))
// 先写够探测用的前缀,再断开:探测能过,透传阶段才断。
payload := make([]byte, 8<<10)
for written := 0; written < 128<<10; written += len(payload) {
if _, err := w.Write(payload); err != nil {
return
}
}
w.(http.Flusher).Flush()
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
}))
defer srv.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
// .mp4 不是音频后缀:走流式透传分支(音轨会被整段落盘缓存)。
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media.mp4")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signed, nil)
// panic(http.ErrAbortHandler) 由 net/http 捕获;httptest 下会向上冒泡,
// 这里断言它确实发生了(即「没有静默地把半截文件当成功」)。
defer func() {
recovered := recover()
if recovered == nil {
t.Fatalf("上游截断时必须中止响应(panic(http.ErrAbortHandler)),实际正常返回了 %d 字节",
w.Body.Len())
}
if recovered != http.ErrAbortHandler {
t.Fatalf("中止响应时应使用 http.ErrAbortHandler,实际 %v", recovered)
}
}()
router.ServeHTTP(w, req)
if w.Body.Len() >= declared {
t.Fatalf("不应交付完整长度的响应体(上游只写了 128KB)")
}
}
// TestReaderAudioCacheServesFromDisk 音轨首次拉取后落盘;后续请求即使带着一张
// 新签的票(query 全变了)也不再回源——这是让播放不受跨境链路抖动影响的关键。
func TestReaderAudioCacheServesFromDisk(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK || !strings.Contains(body, "fake-audio-bytes") {
t.Fatalf("首次拉取失败 status=%d body=%q", code, body)
}
if got := srv.mediaRequests(); got != 1 {
t.Fatalf("首次拉取应对上游请求 1 次,实际 %d", got)
}
// 同一段音频的另一张票:URL 的 query 不同,缓存键必须一致。
rotated := svc.Reader.ProxyURL(book.ID, srv.URL+"/media?t=ticket-rotated")
code, body = getReaderMedia(t, router, rotated)
if code != http.StatusOK || !strings.Contains(body, "fake-audio-bytes") {
t.Fatalf("命中缓存失败 status=%d body=%q", code, body)
}
if got := srv.mediaRequests(); got != 1 {
t.Fatalf("命中缓存后不该再回源,实际上游请求 %d 次", got)
}
}
// TestReaderAudioCacheAbortsOnTruncatedUpstream 首播直通模式下上游持续截断的契约:
//
// 冷启动走边下边发(见 handleStreamingReaderAudio),响应头和前缀字节在探测
// 通过后就已发出,因此中途截断不可能再改写成 502 —— 必须中止连接让客户端
// 判定「传输不完整」,同时保留 .part 分片。
//
// 关键不变量:**绝不能留下可命中的完整缓存**(否则后续会把截断音频当真完整
// 文件下发,播放器表现为「时长正确但没有声音」)。下一次请求带着分片走续传
// 路径,那时才可能给出 502 + 明确文案。
func TestReaderAudioCacheAbortsOnTruncatedUpstream(t *testing.T) {
const declared = 400 << 10
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(declared))
payload := make([]byte, 8<<10)
for written := 0; written < 128<<10; written += len(payload) {
if _, err := w.Write(payload); err != nil {
return
}
}
w.(http.Flusher).Flush()
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
}))
defer srv.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
// 无后缀地址 + 音频书籍 → 走音轨落盘缓存。
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signed, nil)
// panic(http.ErrAbortHandler) 由 net/http 捕获;httptest 下会向上冒泡,
// 这里断言它确实发生了(即「没有静默地把半截音频当成功」)。
func() {
defer func() {
recovered := recover()
if recovered == nil {
t.Fatalf("上游截断时必须中止响应,实际正常返回了 %d 字节", w.Body.Len())
}
if recovered != http.ErrAbortHandler {
t.Fatalf("中止响应时应使用 http.ErrAbortHandler,实际 %v", recovered)
}
}()
router.ServeHTTP(w, req)
}()
if w.Body.Len() >= declared {
t.Fatalf("不应交付完整长度的响应体(上游只写了 128KB)")
}
if _, _, ok := svc.Reader.OpenCachedRemoteAudio(book.ID, srv.URL+"/media"); ok {
t.Fatalf("中断的下载不能留下可命中的完整缓存")
}
if !svc.Reader.HasPartialRemoteAudio(book.ID, srv.URL+"/media") {
t.Fatalf("中断后应保留 .part 分片供续传")
}
// 二次请求带分片走续传路径;上游依旧忽略 Range 并截断 → 这时才给 502。
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("续传路径持续失败应返回 502,实际 %d body=%.120s", code, body)
}
if strings.Contains(body, "ftyp") {
t.Fatalf("不能把半截音频交给客户端: %q", body)
}
if _, _, ok := svc.Reader.OpenCachedRemoteAudio(book.ID, srv.URL+"/media"); ok {
t.Fatalf("失败的续传仍不应留下完整缓存")
}
}
func mediaRouterForTest(svc *service.Container) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
// /api/reader/media 挂在公共 api 组(签名鉴权,不需要 JWT),单独注册即可。
r.GET("/api/reader/media", readerMediaProxyHandler(svc))
return r
}
func getReaderMedia(t *testing.T, router *gin.Engine, signedPath string) (int, string) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signedPath, nil)
router.ServeHTTP(w, req)
return w.Code, w.Body.String()
}
// primeTicketBook 先读一次章节(签出第一张票并落缓存),返回该音轨的签名代理路径。
func primeTicketBook(t *testing.T, svc *service.Container, bookID string) string {
t.Helper()
out, err := svc.Reader.GetContentForBook(t.Context(), "u1", bookID, 0, reader.ContentOptions{})
if err != nil {
t.Fatalf("首次取章节失败: %v", err)
}
if len(out.Tracks) != 1 {
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
}
return out.Tracks[0]
}
// ─── 模拟「中转现签 CDN 直链」的上游 ───────────────────────────────────────
// mediaTicketServer:
// - /book/1 详情页
// - /content 章节正文,每次请求签一张新票
// - /media 媒体本体,只认最新签出的票;旧票回**空的 401**(与腾讯 CDN 一致)
// - /img/* 图片资源,固定 403(模拟防盗链)
type mediaTicketServer struct {
*httptest.Server
mu sync.Mutex
issued []string
hits int
mediaHits int
noToken bool
errorPage bool
alwaysDeny bool
}
func newMediaTicketServer(t *testing.T) *mediaTicketServer {
t.Helper()
s := &mediaTicketServer{}
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/book/1"):
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body><h1>票源之书</h1></body></html>`))
case r.URL.Path == "/content":
s.mu.Lock()
s.hits++
n := s.hits
noToken := s.noToken
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if noToken {
_, _ = w.Write([]byte(`{"data":{"url":""}}`))
return
}
token := s.newTokenLocked(fmt.Sprintf("ticket-%d", n))
_, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media?t=%s"}}`, s.URL, token)))
case r.URL.Path == "/media":
s.mu.Lock()
s.mediaHits++
deny := s.alwaysDeny
s.mu.Unlock()
if deny {
// 与腾讯 CDN 一致:连新签的票也直接空 401(成批拒绝的那几分钟)。
w.WriteHeader(http.StatusUnauthorized)
return
}
if s.errorPageNow() {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body>ticket expired</body></html>`))
return
}
token := r.URL.Query().Get("t")
s.mu.Lock()
valid := len(s.issued) > 0 && s.issued[len(s.issued)-1] == token
s.mu.Unlock()
if !valid {
// 与上游一致:空 body、无 Content-Type。
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "audio/mp4")
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42fake-audio-bytes"))
case strings.HasPrefix(r.URL.Path, "/img/"):
w.WriteHeader(http.StatusForbidden)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(s.Close)
return s
}
// newTokenLocked 签一张新票(调用方需持有或随后获取锁;这里内部加锁)。
func (s *mediaTicketServer) newTokenLocked(token string) string {
s.mu.Lock()
defer s.mu.Unlock()
s.issued = append(s.issued, token)
return token
}
// rotate 模拟「中转又签了一张新票」:章节缓存里那张就此失效。
func (s *mediaTicketServer) rotate() {
s.mu.Lock()
s.issued = append(s.issued, fmt.Sprintf("rotated-%d", len(s.issued)))
s.mu.Unlock()
}
// stopIssuing 让书源此后给不出播放地址(模拟中转本身挂了)。
func (s *mediaTicketServer) stopIssuing() {
s.mu.Lock()
s.noToken = true
s.mu.Unlock()
}
// serveErrorPage 让媒体端点改用 200 + HTML 错误页(直链失效的另一种形态)。
func (s *mediaTicketServer) serveErrorPage() {
s.mu.Lock()
s.errorPage = true
s.mu.Unlock()
}
// denyAlways 让媒体端点连新签的票也一律空 401(模拟上游成批拒绝的那几分钟)。
func (s *mediaTicketServer) denyAlways() {
s.mu.Lock()
s.alwaysDeny = true
s.mu.Unlock()
}
func (s *mediaTicketServer) errorPageNow() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.errorPage
}
func (s *mediaTicketServer) contentHits() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.hits
}
func (s *mediaTicketServer) mediaRequests() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.mediaHits
}
// prepareMediaTicketBookWithUpstream 用指定的上游地址建一本书(上游由调用方提供)。
func prepareMediaTicketBookWithUpstream(t *testing.T, upstream string) (*service.Container, *mediaTicketServer, *model.ReaderBook) {
t.Helper()
svc := newReaderHandlerContainer(t)
svc.Cfg.Cache.CacheDir = t.TempDir()
// 占位 ticket server:只用来满足清理钩子,不参与请求。
srv := newMediaTicketServer(t)
srcJSON := fmt.Sprintf(`{
"bookSourceName": "上游直连测试",
"bookSourceType": 1,
"bookSourceUrl": %q,
"ruleContent": {"content": "$.data.url"}
}`, upstream)
if _, err := svc.Reader.ImportSources(t.Context(), "u1", srcJSON); err != nil {
t.Fatalf("导入书源失败: %v", err)
}
srcs, err := svc.Reader.ListSources(t.Context(), "u1")
if err != nil || len(srcs) == 0 {
t.Fatalf("读不到书源: %v", err)
}
book, err := svc.Reader.AddBook(t.Context(), "u1", reader.SearchOrigin{
SourceID: srcs[0].ID,
Origin: upstream,
OriginName: "上游直连测试",
OriginType: 1,
BookURL: upstream + "/book/1",
}, "上游直连之书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
return svc, srv, book
}
func prepareMediaTicketBook(t *testing.T) (*service.Container, *mediaTicketServer, *model.ReaderBook) {
t.Helper()
svc := newReaderHandlerContainer(t)
// 章节缓存必须落在磁盘上:换票要凭落盘载荷找回「这个地址属于哪一章」。
// newReaderHandlerContainer 只给空 cfg,这里补一个临时缓存目录。
svc.Cfg.Cache.CacheDir = t.TempDir()
srv := newMediaTicketServer(t)
srcJSON := fmt.Sprintf(`{
"bookSourceName": "票源测试",
"bookSourceType": 1,
"bookSourceUrl": %q,
"ruleContent": {"content": "$.data.url"}
}`, srv.URL)
if _, err := svc.Reader.ImportSources(t.Context(), "u1", srcJSON); err != nil {
t.Fatalf("导入书源失败: %v", err)
}
srcs, err := svc.Reader.ListSources(t.Context(), "u1")
if err != nil || len(srcs) == 0 {
t.Fatalf("读不到书源: %v", err)
}
book, err := svc.Reader.AddBook(t.Context(), "u1", reader.SearchOrigin{
SourceID: srcs[0].ID,
Origin: srv.URL,
OriginName: "票源测试",
OriginType: 1,
BookURL: srv.URL + "/book/1",
}, "票源之书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
if err := svc.Reader.SaveChapters(t.Context(), book.ID, []reader.ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
}); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
return svc, srv, book
}
// TestReaderAudioStreamsFirstByteBeforeDownloadCompletes 首播 TTFB 回归:
// 冷启动必须边下边发,而不是等整章下完再一次性下发。
//
// 上游在第一块之后卡住不放:直通实现此时应已把那块转给客户端,所以读端能
// 拿到字节;旧实现(整段下载后才 ServeContent)在 release 关闭前一个字节都
// 不会发出,读端会一直阻塞。
func TestReaderAudioStreamsFirstByteBeforeDownloadCompletes(t *testing.T) {
// 总长必须大于探测窗口(readerMediaProbeBytes=64KB):首播要先读完这一小段
// 才能判断上游给的是错误页还是媒体,这之后才可能外发。卡点在探测结束之后,
// 才能证明「整段下完才发」和「边下边发」的区别。
const chunks = 16
const chunkBytes = 16 << 10
const stallAfter = 4 // 写满探测窗口(4×16KB=64KB)后卡住
release := make(chan struct{})
var releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
defer unblock()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(chunks*chunkBytes))
w.WriteHeader(http.StatusOK)
for i := 0; i < chunks; i++ {
if _, err := w.Write(bytes.Repeat([]byte("m4a!"), chunkBytes/4)); err != nil {
return
}
w.(http.Flusher).Flush()
if i+1 == stallAfter {
// 探测窗口刚写满就卡住,直到测试确认客户端已经收到字节。
<-release
}
}
}))
defer upstream.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, upstream.URL)
signed := svc.Reader.ProxyURL(book.ID, upstream.URL+"/media")
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/reader/media", readerMediaProxyHandler(svc))
proxy := httptest.NewServer(router)
defer proxy.Close()
resp, err := http.Get(proxy.URL + signed)
if err != nil {
t.Fatalf("请求媒体代理失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("直通应返回 200,实际 %d", resp.StatusCode)
}
// 上游仍卡在 release 上:此时能读到第一批字节,才说明是边下边发。
first := make([]byte, 4)
readDone := make(chan error, 1)
go func() {
_, readErr := io.ReadFull(resp.Body, first)
readDone <- readErr
}()
select {
case readErr := <-readDone:
if readErr != nil {
t.Fatalf("读取首批字节失败: %v", readErr)
}
case <-time.After(5 * time.Second):
unblock()
t.Fatalf("首播没有边下边发:上游卡在中途(仍有 %d 字节未发)时客户端读不到任何字节",
(chunks-stallAfter)*chunkBytes)
}
if string(first) != "m4a!" {
t.Fatalf("首批字节内容不对: %q", first)
}
// 放行上游,把剩余字节读完并确认缓存落盘完整。
unblock()
rest, readErr := io.ReadAll(resp.Body)
if readErr != nil {
t.Fatalf("读取剩余字节失败: %v", readErr)
}
if total := 4 + len(rest); total != chunks*chunkBytes {
t.Fatalf("交付字节 = %d,期望 %d", total, chunks*chunkBytes)
}
// 客户端读完最后一个字节时,服务端可能还在做 truncate + rename:
// 落盘完成是 handler 的收尾动作,给一个有限窗口等它。
deadline := time.Now().Add(2 * time.Second)
for !svc.Reader.HasCachedRemoteAudio(book.ID, upstream.URL+"/media") {
if time.Now().After(deadline) {
t.Fatalf("直通完成后应命中缓存")
}
time.Sleep(10 * time.Millisecond)
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -22,6 +22,7 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
registerAdminAPIConfigRoutes(admin, svc)
registerAdminRecognitionWordRoutes(admin, svc)
registerAdminStrmRoutes(admin, svc)
registerAdminCloudDownloadRoutes(admin, svc)
registerAdminScraperRoutes(admin, svc)
registerAdminDatabaseRoutes(admin, svc)
@@ -36,6 +36,9 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
authed.GET("/libraries", listLibrariesHandler(svc))
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.GET("/libraries/:id", getLibraryHandler(svc))
// 服务端生成的媒体库封面(成员海报拼图),Web / App / Emby 共用同一份。
authed.GET("/libraries/:id/cover", libraryCoverHandler(svc))
authed.HEAD("/libraries/:id/cover", libraryCoverHandler(svc))
authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc))
authed.PUT("/libraries/reorder", middleware.AdminRequired(), reorderLibrariesHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
@@ -74,6 +74,7 @@ func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Con
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
authed.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
authed.PUT("/playback/:id/segments", playbackSegmentsMarkHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
+26 -2
View File
@@ -27,21 +27,45 @@ import (
// historyListHandler returns the caller's history rows joined with the
// matching media in a single response.
//
// include_media=0 只返回历史行本身。客户端判断「继续播放」指向哪一集时只
// 需要 media_id/position_ms;带上整条 media 会让 300 条的响应从 ~40KB 涨到
// ~420KB。更重要的是服务端不再 hydrate:不查整条 media、也不为挂载条目
// 逐条请求上游 Emby(见 PlaybackService.RecentHistoryWithoutMedia)。
func historyListHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
userID := toString(uid)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 || limit > 500 {
limit = 50
}
items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit)
includeMedia := true
if raw := strings.TrimSpace(c.Query("include_media")); raw != "" {
if parsed, err := strconv.ParseBool(raw); err == nil {
includeMedia = parsed
}
}
ctx := c.Request.Context()
visibility := mediaVisibilityForRequest(c, svc)
if !includeMedia {
// 可见性由服务层用窄列查询判定,handler 不再需要 media。
items, err := svc.Playback.RecentHistoryWithoutMedia(ctx, userID, limit, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, items)
return
}
items, err := svc.Playback.RecentHistory(ctx, userID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
visibility := mediaVisibilityForRequest(c, svc)
filtered := make([]service.HistoryItem, 0, len(items))
for _, item := range items {
// 可见性按 media 判断:媒体已删除/远端取不到时仍然保留这条历史。
if item.Media == nil || visibility.Allows(item.Media) {
filtered = append(filtered, item)
}
+146
View File
@@ -103,3 +103,149 @@ func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
}
}
// include_media=0 只是不把 media 写进响应,行数和可见性判断都不变。
func TestHistoryListCanOmitEmbeddedMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
media := model.Media{
Base: model.Base{ID: "media-1"},
Title: "影片",
Path: "/media/one.mkv",
}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-1"},
UserID: "user-1",
MediaID: media.ID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: time.Now(),
}).Error; err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history", historyListHandler(svc))
type listRow struct {
MediaID string `json:"media_id"`
Media *model.Media `json:"media"`
}
full := httptest.NewRecorder()
router.ServeHTTP(full, httptest.NewRequest(http.MethodGet, "/watch-history?limit=10", nil))
var fullRows []listRow
if err := json.Unmarshal(full.Body.Bytes(), &fullRows); err != nil {
t.Fatal(err)
}
if len(fullRows) != 1 || fullRows[0].Media == nil || fullRows[0].Media.ID != media.ID {
t.Fatalf("default response should embed media: %s", full.Body.String())
}
lean := httptest.NewRecorder()
router.ServeHTTP(lean, httptest.NewRequest(http.MethodGet, "/watch-history?limit=10&include_media=0", nil))
var leanRows []listRow
if err := json.Unmarshal(lean.Body.Bytes(), &leanRows); err != nil {
t.Fatal(err)
}
if len(leanRows) != 1 || leanRows[0].MediaID != media.ID {
t.Fatalf("lean response should keep the row: %s", lean.Body.String())
}
if leanRows[0].Media != nil {
t.Fatalf("include_media=0 must not embed media: %s", lean.Body.String())
}
}
// TestHistoryListIncludeMediaFalseSkipsHydration 保证 include_media=0 时服务端
// 真的不 hydrate media:响应里只有历史行(客户端按 media_id 匹配续播目标)。
func TestHistoryListIncludeMediaFalseSkipsHydration(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "media-1"},
Title: "影片",
Path: "/media/1.mkv",
}).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
for i, mediaID := range []string{"media-1", "media-deleted"} {
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-" + mediaID},
UserID: "user-1",
MediaID: mediaID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: now.Add(-time.Duration(i) * time.Minute),
}).Error; err != nil {
t.Fatal(err)
}
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history", historyListHandler(svc))
for _, path := range []string{
"/watch-history?limit=10&include_media=0",
"/watch-history?limit=10&include_media=false",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d, body = %s", path, rec.Code, rec.Body.String())
}
var rows []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
t.Fatalf("%s decode: %v", path, err)
}
// 媒体已删除的历史也保留:客户端只按 media_id 判断有没有记录。
if len(rows) != 2 {
t.Fatalf("%s rows = %d, want 2 (%s)", path, len(rows), rec.Body.String())
}
for _, row := range rows {
if _, ok := row["media"]; ok {
t.Fatalf("%s should not embed media: %v", path, row)
}
if row["media_id"] == "" || row["media_id"] == nil {
t.Fatalf("%s missing media_id: %v", path, row)
}
}
}
}
+43 -4
View File
@@ -7,6 +7,7 @@ import (
"compress/flate"
"compress/gzip"
"compress/zlib"
"crypto/tls"
"io"
"net/http"
"net/url"
@@ -88,10 +89,51 @@ func StripAcceptEncoding(h http.Header) {
//
// When useProxy is false, the client is created without proxy plumbing so
// the request goes out direct, matching the user's checkbox intent.
//
// 注意 http.Client.Timeout 是「整个请求」的上限,包含读完响应体。书源的
// 搜索/目录/正文都是小响应,30 秒足够;但同一个 client 也被用来流转发
// 音频/视频(见 NewMediaHTTPClient),那里必须换一个没有整体超时的 client。
func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
if timeoutSeconds <= 0 {
timeoutSeconds = 15
}
return &http.Client{
Timeout: time.Duration(timeoutSeconds) * time.Second,
Transport: newSiteTransport(useProxy),
}
}
// NewMediaHTTPClient 拉取并转发媒体流(音频/视频/大图)的客户端。
//
// 为什么不能用 NewSiteHTTPClient:http.Client.Timeout 会覆盖「读完 body」这个
// 阶段。一个 4MB 的 m4a 在跨境链路上很容易超过 30 秒,一旦超时,net/http 会
// 直接掐断响应体,而调用方的 io.Copy 只是正常返回 —— 表现为**静默截断**
// (播放器拿到半首歌,或判「格式不支持」),或者上游慢到 Do 本身报错 → 502。
//
// 为什么强制 HTTP/1.1:实测腾讯 CDN(vb.stream.tencentmusic.com)的 h2 边缘在
// 长连接大文件上不按预期回 WINDOW_UPDATE,Go 的 http2 Transport 初始连接窗口
// 是 1MiB,于是下载**稳定卡在 1MiB/2MiB/3MiB 整数倍**处断开;同一 URL 用
// HTTP/1.1(wget、curl --http1.1)从头到尾都完整。因此媒体通路固定走 h1.1:
// 音视频本来就没有「多路复用省请求」的收益,反而是流控 bug 的受害者。
func NewMediaHTTPClient(useProxy bool) *http.Client {
tr := newSiteTransport(useProxy)
// 等响应头最多 90 秒:跨境 CDN 首包偶发要几十秒,但绝不能无上限挂着。
tr.ResponseHeaderTimeout = 90 * time.Second
// 媒体是长连接大流量,连接池开大一些,避免并发取图/拖动进度时互相排队。
tr.MaxIdleConns = 32
tr.MaxIdleConnsPerHost = 16
// 关闭 HTTP/2:见上面的流控说明。
tr.ForceAttemptHTTP2 = false
tr.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
// 不复用连接:实测腾讯 CDN 的边缘在「同一条 keep-alive 连接上连续取多个
// 大对象」时会直接 RST 第二个请求的响应体(容器内 wget/curl 每次新建连接
// 就完全正常)。音视频请求本来就稀疏,为省一次 TLS 握手去冒截断风险不划算。
tr.DisableKeepAlives = true
return &http.Client{Transport: tr}
}
// newSiteTransport 书源请求共用的 Transport(媒体与规则请求配置一致的部分)。
func newSiteTransport(useProxy bool) *http.Transport {
tr := &http.Transport{
MaxIdleConns: 16,
MaxIdleConnsPerHost: 4,
@@ -104,10 +146,7 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
return ProxyFromEnvironmentOrSystem(r)
}
}
return &http.Client{
Timeout: time.Duration(timeoutSeconds) * time.Second,
Transport: tr,
}
return tr
}
// HTTPHeaderPresets returns a map of realistic browser HTTP headers.
+12
View File
@@ -17,12 +17,24 @@ var gzipExcludedExtensions = []string{
// apiGzipExcludedPrefixes 大文件流式传输(Range 语义)与 WS/SSE 长连接
// 不参与 gzip:压缩会破坏 Range / 逐块推送语义。
//
// 阅读媒体代理(/api/reader/media、/api/reader/local/audio、
// /api/reader/audio/transcode)同样必须排除,原因有两条:
// - 它们透传上游的 206 Partial Content。gin-contrib/gzip 在压缩时会删掉
// Content-Length,却把 Content-Range 原样留下,客户端看到的是「声明
// N 字节、实际收到 M 字节的 gzip 流」,自己发 Accept-Encoding 的播放器
// (ExoPlayer 不透明处理)直接判 ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED。
// - 音视频本来就是压缩格式,再 gzip 一遍只烧 CPU(3.5MB 的 m4a 压完还是
// 3.5MB 量级),对带宽没有收益。
var apiGzipExcludedPrefixes = []string{
"/api/stream/",
"/api/hls/",
"/api/img",
"/api/subtitles/",
"/api/strm/play/",
"/api/reader/media",
"/api/reader/local/audio",
"/api/reader/audio/transcode",
"/api/ws",
"/api/events",
}
+46
View File
@@ -70,6 +70,52 @@ func TestGzipAPIExcludesStreamPath(t *testing.T) {
}
}
// TestGzipAPIExcludesReaderMedia 阅读媒体代理必须排除在 gzip 之外。
//
// 它会透传 CDN 的 206 Partial Content。gin-contrib/gzip 压缩时会删掉
// Content-Length 却保留 Content-Range,客户端看到「声明 N 字节、实收 M 字节」,
// 自己发 Accept-Encoding 的播放器(ExoPlayer)直接判
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,表现为「音频格式不支持」。
func TestGzipAPIExcludesReaderMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api")
api.Use(GzipAPI())
// 用 206 + Content-Range 模拟真实的音频分段响应。
segment := func(c *gin.Context) {
c.Header("Content-Range", "bytes 0-99/100")
c.Data(http.StatusPartialContent, "audio/mp4", []byte(strings.Repeat("x", 4096)))
}
for _, path := range []string{
"/reader/media",
"/reader/local/audio",
"/reader/audio/transcode",
} {
api.GET(path, segment)
}
for _, path := range []string{
"/reader/media",
"/reader/local/audio",
"/reader/audio/transcode",
} {
req := httptest.NewRequest(http.MethodGet, "/api"+path+"?b=book&u=x&s=y", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if got := w.Header().Get("Content-Encoding"); got == "gzip" {
t.Errorf("%s 不该被 gzip(Range 语义会被破坏)", path)
}
if got := w.Header().Get("Content-Range"); got != "bytes 0-99/100" {
t.Errorf("%s 的 Content-Range 被破坏: %q", path, got)
}
if w.Body.Len() != 4096 {
t.Errorf("%s 响应体长度 = %d,期望 4096", path, w.Body.Len())
}
}
}
func TestGzipStaticCompressesAssets(t *testing.T) {
router := newGzipTestRouter()
+81
View File
@@ -0,0 +1,81 @@
// 115 云下载(离线下载)任务数据模型。
//
// 一条 CloudDownloadTask 表示用户提交的一个离线下载来源(磁力 / BT 种子 /
// ed2k / HTTP),并记录它在「115 下载 → STRM 同步 → 媒体库扫描 → 刮削」
// 链路上的位置。下载状态(Status)与后处理阶段(Stage)刻意分成两组字段:
// STRM 同步或刮削失败不能把 115 侧的「已下载成功」改写掉,用户重做后处理时
// 也不需要重新下载。
package model
import "time"
// 云下载任务状态(115 离线下载侧)。
const (
CloudDownloadQueued = "queued" // 本地已建行,尚未提交 115
CloudDownloadSubmitted = "submitted" // 115 已受理
CloudDownloadDownloading = "downloading" // 115 正在下载(status 0/1)
CloudDownloadSucceeded = "succeeded" // 115 下载完成(status 2)
CloudDownloadFailed = "failed" // 提交被拒 / status -1 / 超时
CloudDownloadCanceled = "canceled" // 用户取消
)
// 云下载后处理阶段(STRM 同步 → 扫描 → 刮削)。
const (
CloudDownloadStagePending = "pending" // 尚未开始(下载未成功)
CloudDownloadStageWaitingSync = "waiting_sync" // 已成功,等待去抖窗口或等待目录空闲
CloudDownloadStageSyncing = "syncing" // STRM 增量同步中
CloudDownloadStageScanning = "scanning" // 媒体库扫描中
CloudDownloadStageScraping = "scraping" // 刮削已入队
CloudDownloadStageDone = "done" // 全链路完成
CloudDownloadStageNA = "na" // 无法继续(同步目录/媒体库不存在),不算失败
CloudDownloadStageFailed = "failed" // 阶段失败,可用「重做后处理」重试
)
// 云下载来源类型。
const (
CloudDownloadSourceMagnet = "magnet"
CloudDownloadSourceTorrent = "torrent"
CloudDownloadSourceED2K = "ed2k"
CloudDownloadSourceHTTP = "http"
CloudDownloadSourceFTP = "ftp"
)
// CloudDownloadTask 是一条云下载任务。
type CloudDownloadTask struct {
Base
AccountID string `gorm:"size:36;index" json:"account_id"` // 115 网盘账号(model.StrmAccount)
SyncPathID string `gorm:"size:36;index" json:"sync_path_id"` // 绑定的 STRM 同步目录
LibraryID string `gorm:"size:36;index" json:"library_id"` // 由本地目录反查得到,可为空
Provider string `gorm:"size:32" json:"provider"` // 固定 cloud115
SourceType string `gorm:"size:16;index" json:"source_type"` // CloudDownloadSource*
SourceURL string `gorm:"type:text" json:"source_url"` // 原始链接;.torrent 时存上传的文件名
// SourceKey 是轮询匹配用的归一化键:BT 类任务存大写 info_hash,链接类任务
// 存 trim 后的 url。提交时即写入,避免每轮重新计算。
SourceKey string `gorm:"size:512;index" json:"source_key"`
InfoHash string `gorm:"size:64;index" json:"info_hash,omitempty"`
TorrentName string `gorm:"size:512" json:"torrent_name,omitempty"`
TorrentSha1 string `gorm:"size:64" json:"torrent_sha1,omitempty"` // .torrent 提交路径用
PickCode string `gorm:"size:128" json:"pick_code,omitempty"` // .torrent 提交路径用
WpPathID string `gorm:"size:64" json:"wp_path_id"` // 115 目标目录 ID(= syncPath.RemotePath)
SavePath string `gorm:"size:512" json:"save_path,omitempty"` // 相对 wp_path_id 的子路径(预留)
Status string `gorm:"size:16;index" json:"status"` // CloudDownload*
Percent int `json:"percent"`
SizeBytes int64 `json:"size_bytes"`
Error string `gorm:"size:1024" json:"error"`
Stage string `gorm:"size:16;index" json:"stage"` // CloudDownloadStage*
StageError string `gorm:"size:1024" json:"stage_error"`
StrmRecordID string `gorm:"size:36" json:"strm_record_id,omitempty"`
ScanAdded int `json:"scan_added"`
ScrapeQueued int `json:"scrape_queued"`
RetryCount int `json:"retry_count"`
LastPollAt *time.Time `json:"last_poll_at,omitempty"`
NextPollAt *time.Time `gorm:"index" json:"next_poll_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
+1
View File
@@ -60,6 +60,7 @@ func AllModels() []interface{} {
&StrmDownloadTask{},
&StrmUploadTask{},
&StrmDirCache{},
&CloudDownloadTask{},
&ScrapeTask{},
&BackgroundTaskRecord{},
&EmbyMount{},
+6
View File
@@ -38,6 +38,12 @@ type ReaderBookSource struct {
// NeedsBrowser 是否依赖 WebView/无头浏览器(webView 选项或 webjs 规则)。
// 服务端没有浏览器,这类源注定不可用,列表接口按需计算让前端能提前提示。
NeedsBrowser bool `gorm:"-" json:"needs_browser"`
// MediaDirect 允许客户端直连上游取实体媒体(音频/漫画图片),服务端只负责
// 解析地址与下发所需请求头。
//
// 只在客户端显式请求直连时生效(见 ContentOptions):网页端不声明该能力,
// 行为与开启此字段前完全一致。
MediaDirect bool `gorm:"default:false" json:"media_direct"`
}
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
@@ -0,0 +1,241 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// CloudDownloadRepository persists model.CloudDownloadTask(115 云下载任务)。
type CloudDownloadRepository struct{ db *gorm.DB }
// CloudDownloadQuery 是任务列表的过滤条件。
type CloudDownloadQuery struct {
Status string // 精确匹配;空表示全部
SyncPathID string
AccountID string
Page int
PageSize int
}
// Create 插入一条云下载任务。
func (r *CloudDownloadRepository) Create(ctx context.Context, t *model.CloudDownloadTask) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(t).Error
})
}
// Update 更新任务的可变字段(不含主键与创建时间)。
func (r *CloudDownloadRepository) Update(ctx context.Context, t *model.CloudDownloadTask) error {
return r.UpdateFields(ctx, t.ID, map[string]any{
"account_id": t.AccountID,
"sync_path_id": t.SyncPathID,
"library_id": t.LibraryID,
"provider": t.Provider,
"source_type": t.SourceType,
"source_url": t.SourceURL,
"source_key": t.SourceKey,
"info_hash": t.InfoHash,
"torrent_name": t.TorrentName,
"torrent_sha1": t.TorrentSha1,
"pick_code": t.PickCode,
"wp_path_id": t.WpPathID,
"save_path": t.SavePath,
"status": t.Status,
"percent": t.Percent,
"size_bytes": t.SizeBytes,
"error": t.Error,
"stage": t.Stage,
"stage_error": t.StageError,
"strm_record_id": t.StrmRecordID,
"scan_added": t.ScanAdded,
"scrape_queued": t.ScrapeQueued,
"retry_count": t.RetryCount,
"last_poll_at": t.LastPollAt,
"next_poll_at": t.NextPollAt,
"completed_at": t.CompletedAt,
})
}
// UpdateFields 局部更新:调用方只写自己关心的列,避免覆盖并发写入的其它阶段字段
// (轮询线程写 Status,后处理线程写 Stage,两者必须互不覆盖)。
func (r *CloudDownloadRepository) UpdateFields(ctx context.Context, id string, fields map[string]any) error {
if id == "" || len(fields) == 0 {
return nil
}
fields["updated_at"] = time.Now()
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.CloudDownloadTask{}).Where("id = ?", id).Updates(fields).Error
})
}
// FindByID 按主键查询,未找到返回 (nil, nil)。
func (r *CloudDownloadRepository) FindByID(ctx context.Context, id string) (*model.CloudDownloadTask, error) {
var t model.CloudDownloadTask
err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &t, nil
}
// List 分页查询任务列表,同时返回总数。
func (r *CloudDownloadRepository) List(ctx context.Context, q CloudDownloadQuery) ([]model.CloudDownloadTask, int64, error) {
db := r.db.WithContext(ctx).Model(&model.CloudDownloadTask{})
if q.Status != "" {
db = db.Where("status = ?", q.Status)
}
if q.SyncPathID != "" {
db = db.Where("sync_path_id = ?", q.SyncPathID)
}
if q.AccountID != "" {
db = db.Where("account_id = ?", q.AccountID)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
page := q.Page
if page < 1 {
page = 1
}
size := q.PageSize
if size <= 0 {
size = 20
}
if size > 200 {
size = 200
}
var rows []model.CloudDownloadTask
err := db.Order("created_at desc").
Offset((page - 1) * size).
Limit(size).
Find(&rows).Error
return rows, total, err
}
// ListByStatuses 取指定状态的任务(轮询用)。
func (r *CloudDownloadRepository) ListByStatuses(ctx context.Context, statuses []string) ([]model.CloudDownloadTask, error) {
if len(statuses) == 0 {
return nil, nil
}
var rows []model.CloudDownloadTask
err := r.db.WithContext(ctx).
Where("status IN ?", statuses).
Order("created_at asc").
Find(&rows).Error
return rows, err
}
// ListBySyncPathAndStage 取某个同步目录下处于指定阶段的任务(后处理推进用)。
func (r *CloudDownloadRepository) ListBySyncPathAndStage(ctx context.Context, syncPathID, stage string) ([]model.CloudDownloadTask, error) {
var rows []model.CloudDownloadTask
err := r.db.WithContext(ctx).
Where("sync_path_id = ? AND stage = ?", syncPathID, stage).
Order("created_at asc").
Find(&rows).Error
return rows, err
}
// ListPipelineCandidates 取「下载已成功、后处理尚未完成」的任务。
func (r *CloudDownloadRepository) ListPipelineCandidates(ctx context.Context) ([]model.CloudDownloadTask, error) {
var rows []model.CloudDownloadTask
err := r.db.WithContext(ctx).
Where("status = ? AND stage IN ?", model.CloudDownloadSucceeded,
[]string{model.CloudDownloadStagePending, model.CloudDownloadStageWaitingSync}).
Order("created_at asc").
Find(&rows).Error
return rows, err
}
// ResetStaleStages 把上次进程退出时停在中间阶段的任务重置为待推进。
// STRM 同步/扫描/刮削都是幂等操作,重启后重新推进是安全的。
func (r *CloudDownloadRepository) ResetStaleStages(ctx context.Context) (int64, error) {
var affected int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.CloudDownloadTask{}).
Where("stage IN ?", []string{
model.CloudDownloadStageSyncing,
model.CloudDownloadStageScanning,
model.CloudDownloadStageScraping,
}).
Updates(map[string]any{
"stage": model.CloudDownloadStagePending,
"stage_error": "",
"updated_at": time.Now(),
})
affected = res.RowsAffected
return res.Error
})
return affected, err
}
// FindActiveBySourceKeys 返回给定匹配键中仍处于活跃状态的任务键集合。
// 用于提交时去重:同一个磁力重复提交只会浪费 115 配额。
func (r *CloudDownloadRepository) FindActiveBySourceKeys(ctx context.Context, keys []string) (map[string]bool, error) {
out := make(map[string]bool, len(keys))
if len(keys) == 0 {
return out, nil
}
var found []string
err := r.db.WithContext(ctx).Model(&model.CloudDownloadTask{}).
Where("source_key IN ?", keys).
Where("status IN ?", []string{
model.CloudDownloadQueued,
model.CloudDownloadSubmitted,
model.CloudDownloadDownloading,
}).
Pluck("source_key", &found).Error
if err != nil {
return out, err
}
for _, k := range found {
out[k] = true
}
return out, nil
}
// Delete 硬删除一条任务记录。
func (r *CloudDownloadRepository) Delete(ctx context.Context, id string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.CloudDownloadTask{}).Error
})
}
// ClearFinished 删除所有「无后续价值」的终态任务(失败/已取消)。
// 成功的任务保留:它们承载「本地已生成 strm / 已刮削」的证据,供用户回查。
func (r *CloudDownloadRepository) ClearFinished(ctx context.Context) (int64, error) {
var affected int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().
Where("status IN ?", []string{
model.CloudDownloadFailed,
model.CloudDownloadCanceled,
}).
Delete(&model.CloudDownloadTask{})
affected = res.RowsAffected
return res.Error
})
return affected, err
}
// CountBySyncPath 统计某同步目录下仍在活跃的任务数(前端展示用)。
func (r *CloudDownloadRepository) CountBySyncPath(ctx context.Context, syncPathID string) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.CloudDownloadTask{}).
Where("sync_path_id = ?", syncPathID).
Where("status IN ?", []string{
model.CloudDownloadQueued,
model.CloudDownloadSubmitted,
model.CloudDownloadDownloading,
}).
Count(&n).Error
return n, err
}
+14
View File
@@ -123,6 +123,20 @@ func (r *LibraryRepository) ListRoots(ctx context.Context, libraryID string) ([]
return roots, err
}
// ListEnabledRoots 返回全部已启用媒体库的全部启用路径。
// 用于「本地目录 → 媒体库」的反查(云下载完成后只扫相关库,而不是全库重扫)。
func (r *LibraryRepository) ListEnabledRoots(ctx context.Context) ([]model.LibraryRoot, error) {
if !r.hasLibraryRootsTable() {
return nil, nil
}
var roots []model.LibraryRoot
err := r.db.WithContext(ctx).
Where("enabled = ?", true).
Order("sort_order asc, created_at asc").
Find(&roots).Error
return roots, err
}
func (r *LibraryRepository) FindRootByID(ctx context.Context, libraryID, rootID string) (*model.LibraryRoot, error) {
if !r.hasLibraryRootsTable() {
return nil, nil
+8 -2
View File
@@ -237,10 +237,16 @@ func (r *MediaRepository) ListSeasonSiblings(ctx context.Context, m *model.Media
} else {
libraryID := strings.TrimSpace(m.LibraryID)
title := strings.TrimSpace(m.Title)
if libraryID == "" || title == "" || m.TMDbID <= 0 {
if libraryID == "" || title == "" {
return nil, nil
}
query = query.Where("library_id = ? AND title = ? AND tm_db_id = ?", libraryID, title, m.TMDbID)
// 没有 series_id 也没有 TMDb id 时,退到「同库 + 同标题」:手动标记的按季
// 传播是用户显式操作,同库同名已经足够可靠;而在缺 id 时直接放弃会让这个
// 功能在未刮削的库上完全不生效。
query = query.Where("library_id = ? AND title = ?", libraryID, title)
if m.TMDbID > 0 {
query = query.Where("tm_db_id = ?", m.TMDbID)
}
}
var rows []model.Media
if err := query.Find(&rows).Error; err != nil {
@@ -56,6 +56,23 @@ func (r *MediaSegmentRepository) ReplaceForMedia(ctx context.Context, mediaID, s
})
}
// ReplaceForMediaSourceKind swaps the segments of one (media, source, kind)
// triple in a single transaction. Used by manual marking, where the user sets
// one boundary at a time and must not disturb the other kinds.
func (r *MediaSegmentRepository) ReplaceForMediaSourceKind(ctx context.Context, mediaID, source, kind string, rows []model.MediaSegment) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().
Where("media_id = ? AND source = ? AND kind = ?", mediaID, source, kind).
Delete(&model.MediaSegment{}).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
}
// GetFetch returns the fetch ledger row for (media, source), or (nil, nil).
func (r *MediaSegmentRepository) GetFetch(ctx context.Context, mediaID, source string) (*model.MediaSegmentFetch, error) {
var row model.MediaSegmentFetch
+2
View File
@@ -35,6 +35,7 @@ type Container struct {
StrmDownload *StrmDownloadTaskRepository
StrmUpload *StrmUploadTaskRepository
StrmDirCache *StrmDirCacheRepository
CloudDownload *CloudDownloadRepository
ScrapeTask *ScrapeTaskRepository
EmbyMount *EmbyMountRepository
Reader *ReaderRepository
@@ -69,6 +70,7 @@ func New(db *gorm.DB) *Container {
StrmDownload: &StrmDownloadTaskRepository{db: db},
StrmUpload: &StrmUploadTaskRepository{db: db},
StrmDirCache: &StrmDirCacheRepository{db: db},
CloudDownload: &CloudDownloadRepository{db: db},
ScrapeTask: &ScrapeTaskRepository{db: db},
EmbyMount: &EmbyMountRepository{db: db},
Reader: &ReaderRepository{db: db},
+69 -8
View File
@@ -8,6 +8,7 @@ import (
_ "image/png" // register PNG decoder
"strings"
"github.com/metatube-community/metatube-sdk-go/common/number"
"github.com/metatube-community/metatube-sdk-go/detector"
_ "golang.org/x/image/webp" // register WebP decoder for AVE covers
)
@@ -46,32 +47,50 @@ func IsAdultArtworkURL(raw string) bool {
strings.Contains(lower, "pics.dmm.co.jp")
}
// CropAdultCoverPoster checks if the image data is a wide full-jacket DVD cover (width > height * 1.15).
// If so, it uses MetaTube's Pigo face detector to position a 2:3 poster crop.
// When no face is detected, it falls back to the conventional right-side front cover.
// CropAdultCoverPoster checks if the image data is a wide full-jacket DVD cover (width > height * 1.15)
// and crops the conventional right-side front cover out of it. It is the code-agnostic entry point;
// prefer CropAdultCoverPosterForCode when the movie number is known.
// If the image is already portrait or cannot be decoded, it safely returns the original data.
func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
return CropAdultCoverPosterForCode(data, "")
}
// CropAdultCoverPosterForCode crops a 2:3 poster out of a wide full-jacket DVD cover.
//
// Layout matters: a regular DVD jacket is [封底 | 书脊 | 封面] unfolded right-to-left, so the
// front cover is always the right-most panel — exactly what upstream MetaTube does with its
// default position 1.0. Face detection is only meaningful for "special" numbers
// (FC2 / HEYZO / 素人 / 无码) whose cover is a snapshot whose composition varies
// (see metatube-sdk-go/common/number.RequiresFaceDetection).
//
// Running the detector on a regular jacket is actively harmful: the back cover of those jackets is a
// montage of sample stills that is packed with faces, so the dominant face cluster lands on the montage
// (or right on the spine), and a crop centred there straddles the seam giving "half back cover + half
// front cover" posters.
func CropAdultCoverPosterForCode(data []byte, code string) ([]byte, string, error) {
if len(data) == 0 {
return data, "", nil
}
src, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return data, format, err
return data, imageMIMEForFormat(format), err
}
bounds := src.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width <= 0 || height <= 0 {
return data, format, nil
return data, imageMIMEForFormat(format), nil
}
// If the image is already portrait or square, keep it as-is.
if float64(width) <= float64(height)*1.15 {
return data, format, nil
return data, imageMIMEForFormat(format), nil
}
position := 1.0
if detected, ok := findPrimaryFaceAxisRatio(src, adultPosterRatio, true); ok {
position = detected
if adultCoverNeedsFaceDetection(code) {
if detected, ok := findPrimaryFaceAxisRatio(src, adultPosterRatio, true); ok {
position = snapAdultCoverPosition(detected)
}
}
cropped := cropImageAtPosition(src, adultPosterRatio, position)
@@ -82,6 +101,48 @@ func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
return buf.Bytes(), "image/jpeg", nil
}
// imageMIMEForFormat maps the format name returned by image.Decode ("jpeg" / "png" / "webp" …) to a MIME
// type. The sidecar extension is derived from the MIME type, so handing back the bare format name made
// every already-portrait poster fall through to the legacy ".img" extension.
func imageMIMEForFormat(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "jpeg", "jpg":
return "image/jpeg"
case "png":
return "image/png"
case "webp":
return "image/webp"
case "gif":
return "image/gif"
case "avif":
return "image/avif"
default:
return ""
}
}
// adultCoverNeedsFaceDetection reports whether the cover composition has to be probed with the face
// detector, mirroring upstream MetaTube: only "special" numbers (FC2 / HEYZO / 素人 / 无码) qualify,
// regular DVD numbers are always cropped at the right-most panel.
func adultCoverNeedsFaceDetection(code string) bool {
code = strings.TrimSpace(code)
if code == "" {
return false
}
return number.RequiresFaceDetection(code)
}
// snapAdultCoverPosition quantises a detected face axis onto a whole panel. A wide jacket only has two
// legal framings — the left panel or the right panel — so the crop window must never be centred on the
// detected axis itself: when that axis sits on/near the spine the result is a poster showing the right
// half of the back cover plus the left sliver of the front cover.
func snapAdultCoverPosition(position float64) float64 {
if position >= 0.5 {
return 1.0 // right panel: flush with the right edge
}
return 0.0 // left panel: flush with the left edge
}
func cropImageAtPosition(src image.Image, ratio, position float64) image.Image {
bounds := src.Bounds()
width := bounds.Dx()
+67 -4
View File
@@ -80,7 +80,7 @@ func TestCropAdultCoverPosterDecodesWebP(t *testing.T) {
}
}
func TestCropAdultCoverPosterCentersDetectedFace(t *testing.T) {
func TestCropAdultCoverPosterUsesDetectedPanelForSpecialNumber(t *testing.T) {
originalDetector := findPrimaryFaceAxisRatio
findPrimaryFaceAxisRatio = func(image.Image, float64, bool) (float64, bool) {
return 0.25, true
@@ -90,7 +90,8 @@ func TestCropAdultCoverPosterCentersDetectedFace(t *testing.T) {
}()
origBytes := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
croppedBytes, _, err := CropAdultCoverPoster(origBytes)
// 素人/FC2 这类番号构图不固定,允许人脸检测决定取哪一侧面板。
croppedBytes, _, err := CropAdultCoverPosterForCode(origBytes, "FC2-PPV-123456")
if err != nil {
t.Fatal(err)
}
@@ -100,7 +101,65 @@ func TestCropAdultCoverPosterCentersDetectedFace(t *testing.T) {
}
r, _, b, _ := croppedImg.At(croppedImg.Bounds().Dx()/2, croppedImg.Bounds().Dy()/2).RGBA()
if r <= b {
t.Fatalf("face-positioned crop did not follow detected left-side axis: r=%d b=%d", r, b)
t.Fatalf("special-number crop did not follow detected left-side axis: r=%d b=%d", r, b)
}
}
// 常规 DVD 番号的封套是 [封底 | 书脊 | 封面] 横图,封底那半张是样本剧照拼贴、充满人脸。
// 做人脸检测会让主簇落在拼贴或书脊上,裁出「半张封底 + 半张封面」。
// 这里模拟检测器给出跨缝的 0.42,要求常规番号完全忽略检测结果,只裁最右侧封面面板。
func TestCropAdultCoverPosterIgnoresFaceDetectionForStandardNumber(t *testing.T) {
originalDetector := findPrimaryFaceAxisRatio
findPrimaryFaceAxisRatio = func(image.Image, float64, bool) (float64, bool) {
return 0.42, true
}
defer func() {
findPrimaryFaceAxisRatio = originalDetector
}()
origBytes := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
croppedBytes, _, err := CropAdultCoverPosterForCode(origBytes, "SSNI-677")
if err != nil {
t.Fatal(err)
}
croppedImg, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
t.Fatal(err)
}
r, _, b, _ := croppedImg.At(croppedImg.Bounds().Dx()/2, croppedImg.Bounds().Dy()/2).RGBA()
if b <= r {
t.Fatalf("standard-number crop should be the right-most panel (blue), got r=%d b=%d", r, b)
}
}
// 即使需要人脸定位,裁切框也必须吸附到整块面板,绝不能跨在书脊上。
func TestCropAdultCoverPosterSnapsToPanelForSpecialNumber(t *testing.T) {
originalDetector := findPrimaryFaceAxisRatio
findPrimaryFaceAxisRatio = func(image.Image, float64, bool) (float64, bool) {
return 0.42, true // 介于两个面板之间:落在书脊上
}
defer func() {
findPrimaryFaceAxisRatio = originalDetector
}()
origBytes := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
croppedBytes, _, err := CropAdultCoverPosterForCode(origBytes, "HEYZO-1234")
if err != nil {
t.Fatal(err)
}
croppedImg, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
t.Fatal(err)
}
bounds := croppedImg.Bounds()
// 左侧面板 = 纯红;任何偏蓝像素都意味着裁切框越过了中线。
for y := bounds.Min.Y; y < bounds.Max.Y; y += 8 {
for x := bounds.Min.X; x < bounds.Max.X; x += 8 {
r, _, b, _ := croppedImg.At(x, y).RGBA()
if b > r {
t.Fatalf("crop straddles the panel seam at (%d,%d): r=%d b=%d", x, y, r, b)
}
}
}
}
@@ -108,10 +167,14 @@ func TestCropAdultCoverPosterKeepsPortrait(t *testing.T) {
// Create an already vertical portrait image (500x700)
origBytes := createTestImage(500, 700, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
croppedBytes, _, err := CropAdultCoverPoster(origBytes)
croppedBytes, ctype, err := CropAdultCoverPoster(origBytes)
if err != nil {
t.Fatalf("CropAdultCoverPoster error: %v", err)
}
// 未裁剪时必须回传真正的 MIME(image/jpeg),否则侧车文件扩展名会退化成 .img。
if ctype != "image/jpeg" {
t.Fatalf("portrait ctype = %q, want image/jpeg", ctype)
}
croppedImg, _, err := image.Decode(bytes.NewReader(croppedBytes))
if err != nil {
@@ -96,7 +96,8 @@ func TestAdultProviderRouting(t *testing.T) {
if len(candidates) != 1 {
t.Fatalf("expected 1 candidate, got %d", len(candidates))
}
wantPoster := mtServer.URL + "/v1/images/primary/javdb/999?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
// SSIS-001 是常规番号:MetaTube 代理不得开启人脸定位,固定取最右侧封面面板。
wantPoster := mtServer.URL + "/v1/images/primary/javdb/999?auto=false&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
wantBackdrop := mtServer.URL + "/v1/images/backdrop/javdb/999?quality=90"
if candidates[0].PosterURL != wantPoster || candidates[0].BackdropURL != wantBackdrop {
t.Fatalf("candidate artwork was not enriched: %#v", candidates[0])
@@ -170,8 +171,8 @@ func TestBuiltinAdultScrapeUsesMetaTubeFaceAwareArtwork(t *testing.T) {
if match == nil {
t.Fatal("expected built-in match")
}
wantPoster := metaTubeServer.URL + "/v1/images/primary/AVE/999?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fwide-cover.jpg"
wantPoster := metaTubeServer.URL + "/v1/images/primary/AVE/999?auto=false&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fwide-cover.jpg"
if match.PosterURL != wantPoster {
t.Fatalf("built-in poster = %q, want face-aware URL %q", match.PosterURL, wantPoster)
t.Fatalf("built-in poster = %q, want MetaTube-processed URL %q", match.PosterURL, wantPoster)
}
}
+218 -34
View File
@@ -74,22 +74,20 @@ type ImageCacheScope struct {
DropUnknownResizeVariants bool
}
// ImageCachePool 描述一个图片缓存池:范围 + 自身配额 + 保留时长。
// ImageCachePool 描述一个图片缓存池:范围 + 自身配额。
//
// 分池的意义在于两类缓存的“可再生成本”完全不同:原图只是生成缩略图的原料,
// 丢了可以重新回源;派生缩略图是客户端热路径真正读取的成品,重新生成代价高。
// 因此原图配小配额、短保留,总量超限时也优先淘汰原图。
// 因此原图配小配额,总量超限时也优先淘汰原图。
//
// 淘汰只看占用(配额头寸),不按文件年龄删除:缩略图生成成本高,只要磁盘还
// 放得下就该留着,让再次浏览直接命中缓存。
type ImageCachePool struct {
Name string
Scopes []ImageCacheScope
MaxBytes int64 // 0 = 不单独限制
MaxAge time.Duration // 0 = 不按时间淘汰
MaxBytes int64 // 0 = 不单独限制
}
// imageFailMarkerMaxAge 是失败标记文件的保留时长:标记只用于观测/重试,
// 过期即视为噪音清掉(不会因为标记残留而阻止重新抓取)。
const imageFailMarkerMaxAge = 24 * time.Hour
// tempImageCacheFile 报告文件名是否是下载/缩放过程中的临时文件。它们正在被
// 并发写入,清理时必须跳过。
func tempImageCacheFile(name string) bool {
@@ -182,12 +180,14 @@ func removeImageCacheEntry(entry imageCacheFileEntry, result *PruneImageCacheRes
_ = os.Remove(entry.path + ".fail")
}
// PruneImageCachePools 按池清理图片缓存:
// 1. 删除超过池 MaxAge 的文件(原图按保留时长淘汰);
// 2. 池自身超过 MaxBytes 时按 mtime 淘汰到 80%(留水位,避免连续写入即触发);
// 3. 全部池合计超过 totalBytes 时,仍按“先原图、后派生”的顺序淘汰最旧文件,
// PruneImageCachePools 按池清理图片缓存,只做占用淘汰:
// 1. 池自身超过 MaxBytes 时按 mtime 淘汰到 80%(留水位,避免连续写入即触发);
// 2. 全部池合计超过 totalBytes 时,仍按“先原图、后派生”的顺序淘汰最旧文件,
// 使总量上限始终是硬保证,同时让客户端热路径的缩略图活得更久。
//
// 不按文件年龄删除:过期即删会让一张几天前看过的海报重新解码(2 核机器上
// 每张约 0.2 秒),代价远高于占着那几十 KB 磁盘。
//
// 传入的池顺序即总量超限时的淘汰优先级(排在前面的先被淘汰)。
func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageCacheResult, error) {
var result PruneImageCacheResult
@@ -207,16 +207,18 @@ func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageC
}
result.RemainingBytes = result.TotalBytesBefore
// 过期的失败标记直接清掉:它们不计容量,只用于观测与重试。
cutoffMarkers := time.Now().Add(-imageFailMarkerMaxAge)
// 失败标记不计入容量:只清掉对应缓存文件已经不存在的孤儿标记(它们只是
// 观测/重试用的噪音,留着没有任何作用)。
for _, marker := range failMarkers {
if marker.modTime.Before(cutoffMarkers) {
_ = os.Remove(marker.path)
base := strings.TrimSuffix(marker.path, ".fail")
if _, err := os.Stat(base); err == nil {
continue
}
_ = os.Remove(marker.path)
}
now := time.Now()
deleted := make(map[string]bool, len(entries))
deleteEntry := func(entry imageCacheFileEntry) {
if deleted[entry.path] {
return
@@ -225,20 +227,7 @@ func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageC
removeImageCacheEntry(entry, &result)
}
// 1. 按池保留时长淘汰(原图池)。
for i := range pools {
if pools[i].MaxAge <= 0 {
continue
}
cutoff := now.Add(-pools[i].MaxAge)
for _, entry := range entries {
if entry.pool == i && entry.modTime.Before(cutoff) {
deleteEntry(entry)
}
}
}
// 2. 池自身配额。
// 1. 池自身配额。
for i := range pools {
maxBytes := pools[i].MaxBytes
if maxBytes <= 0 {
@@ -263,7 +252,7 @@ func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageC
}
}
// 3. 总量硬上限:跨池按淘汰优先级(池顺序)再按 mtime 淘汰。
// 2. 总量硬上限:跨池按淘汰优先级(池顺序)再按 mtime 淘汰。
if totalBytes > 0 && result.RemainingBytes > totalBytes {
target := totalBytes * 80 / 100
for _, entry := range evictionOrder(entries, deleted) {
@@ -357,11 +346,10 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul
// 第一个池是原图:历史版本平铺在 images/ 下的旧缓存也归入此池,升级后会被
// 逐步淘汰;第二个池是派生成品(本地缩放结果 + 挂载 Emby 按尺寸返回的成品)。
// 池顺序即总量超限时的淘汰优先级。
func ImageCachePools(imagesDir string, originalsMaxBytes int64, originalsMaxAge time.Duration) []ImageCachePool {
func ImageCachePools(imagesDir string, originalsMaxBytes int64) []ImageCachePool {
originals := ImageCachePool{
Name: "originals",
MaxBytes: originalsMaxBytes,
MaxAge: originalsMaxAge,
Scopes: []ImageCacheScope{
{Root: imagesDir, Recursive: false}, // 旧版平铺布局
{Root: filepath.Join(imagesDir, imageOriginalCacheSubdir), Recursive: true},
@@ -376,3 +364,199 @@ func ImageCachePools(imagesDir string, originalsMaxBytes int64, originalsMaxAge
}
return []ImageCachePool{originals, derived}
}
// CacheCleanupResult 是全缓存容量淘汰的统计。
type CacheCleanupResult struct {
TotalFilesBefore int
TotalBytesBefore int64
DeletedFiles int
FreedBytes int64
RemainingBytes int64
// FreedByPool 记录每个池释放的字节数,方便日志定位到底是哪类缓存占满了。
FreedByPool map[string]int64
}
// cacheEvictionEntry 是全局容量淘汰里的一个候选文件。
type cacheEvictionEntry struct {
path string
pool string
size int64
modTime time.Time
}
// cacheEvictionPoolOrder 是全局容量清理的固定优先级。
// 顺序即“先清谁”:超限时前面的池会被整池清空,才会轮到后面的池。
// reader-audio 不在其中:它由自身固定配额(当前 1GiB,见 reader 包的
// audioMediaMaxBytes)在写入时独立淘汰。
var cacheEvictionPoolOrder = []string{
"hls",
"images",
"library-covers",
}
// cacheCleanupKeepPercent 是触发清理后回落到的水位(占上限的百分比)。
// 留出余量,避免刚清完又被下一次写入立刻顶到上限。
const cacheCleanupKeepPercent = 80
// tempCacheFile 判断文件名是否是缓存写入中的临时产物。这些文件正在被并发
// 写入,容量统计和清理都应跳过。
func tempCacheFile(name string) bool {
if tempImageCacheFile(name) {
return true
}
if strings.HasSuffix(name, ".part") {
return true
}
// 各缓存目录的原子写入临时文件前缀:.content- / .cover- / .audio- / .js-cache-。
return strings.HasPrefix(name, ".content-") ||
strings.HasPrefix(name, ".cover-") ||
strings.HasPrefix(name, ".audio-") ||
strings.HasPrefix(name, ".js-cache-")
}
// collectCachePoolFiles 递归收集一个普通缓存池里的候选文件,按 mtime 从旧到新。
func collectCachePoolFiles(root, pool string) []cacheEvictionEntry {
if strings.TrimSpace(root) == "" {
return nil
}
var out []cacheEvictionEntry
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info == nil || info.IsDir() {
return nil
}
if !info.Mode().IsRegular() {
return nil
}
name := info.Name()
if tempCacheFile(name) || strings.HasSuffix(name, ".fail") {
return nil
}
out = append(out, cacheEvictionEntry{
path: path,
pool: pool,
size: info.Size(),
modTime: info.ModTime(),
})
return nil
})
sort.Slice(out, func(i, j int) bool { return out[i].modTime.Before(out[j].modTime) })
return out
}
// collectImageCacheEntries 收集图片缓存候选文件。返回顺序沿用图片池的优先级:
// 先原图、后派生成品,池内按 mtime 从旧到新。
func collectImageCacheEntries(imagesDir string) []cacheEvictionEntry {
if strings.TrimSpace(imagesDir) == "" {
return nil
}
if _, err := os.Stat(imagesDir); err != nil {
return nil
}
pools := ImageCachePools(imagesDir, 0)
var entries []imageCacheFileEntry
var failMarkers []imageCacheFileEntry
for i, pool := range pools {
collectImageCachePoolFiles(pool, i, &entries, &failMarkers)
}
ordered := evictionOrder(entries, map[string]bool{})
out := make([]cacheEvictionEntry, 0, len(ordered))
for _, e := range ordered {
out = append(out, cacheEvictionEntry{
path: e.path,
pool: "images",
size: e.size,
modTime: e.modTime,
})
}
return out
}
// removeEmptyDirsUnder 删除 root 下淘汰后留下的空子目录(不含 root 本身)。
func removeEmptyDirsUnder(root string) {
if strings.TrimSpace(root) == "" {
return
}
if _, err := os.Stat(root); err != nil {
return
}
dirs := []string{}
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if info.IsDir() && path != root {
dirs = append(dirs, path)
}
return nil
})
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
}
// PruneCache 按全局容量池清理磁盘缓存。
//
// 总量口径是 hls、images、library-covers 三个池之和;reader-audio 及正文缓存、
// 书源文件缓存都不参与这里的总量计算——reader-audio 由自身固定配额(当前
// 1GiB)在写入时淘汰,正文缓存和书源文件缓存另有自己的 TTL / 配额。
// 超过 totalBytes 时严格按 hls → images → library-covers 的顺序淘汰最旧文件,
// 直到回落到上限的 80%。
// originalsMaxBytes 是 images 原图池的独立子配额,在总量淘汰之后单独执行
// (即使总量没超也会把原图压回配额)。
func PruneCache(cacheDir string, totalBytes, originalsMaxBytes int64) (CacheCleanupResult, error) {
result := CacheCleanupResult{FreedByPool: map[string]int64{}}
if strings.TrimSpace(cacheDir) == "" {
return result, nil
}
imagesDir := filepath.Join(cacheDir, "images")
ordered := make([]cacheEvictionEntry, 0, 1024)
for _, name := range cacheEvictionPoolOrder {
if name == "images" {
ordered = append(ordered, collectImageCacheEntries(imagesDir)...)
continue
}
ordered = append(ordered, collectCachePoolFiles(filepath.Join(cacheDir, name), name)...)
}
result.TotalFilesBefore = len(ordered)
for _, entry := range ordered {
result.TotalBytesBefore += entry.size
}
result.RemainingBytes = result.TotalBytesBefore
// 1. 全局总量:严格按池优先级淘汰,直到回落到水位。
if totalBytes > 0 && result.RemainingBytes > totalBytes {
target := totalBytes * cacheCleanupKeepPercent / 100
for _, entry := range ordered {
if result.RemainingBytes <= target {
break
}
if err := os.Remove(entry.path); err != nil {
continue
}
result.DeletedFiles++
result.FreedBytes += entry.size
result.RemainingBytes -= entry.size
result.FreedByPool[entry.pool] += entry.size
_ = os.Remove(entry.path + ".fail")
}
}
// 2. 图片原图子配额独立于总量:即使总量没超,也要把原图压回自己的配额,
// 同时清掉旧命名变体和孤儿失败标记。
if _, err := os.Stat(imagesDir); err == nil {
pools := ImageCachePools(imagesDir, originalsMaxBytes)
if sub, err := PruneImageCachePools(pools, 0); err == nil {
result.DeletedFiles += sub.DeletedFiles
result.FreedBytes += sub.FreedBytes
result.RemainingBytes -= sub.FreedBytes
result.FreedByPool["images"] += sub.FreedBytes
}
}
for _, name := range cacheEvictionPoolOrder {
removeEmptyDirsUnder(filepath.Join(cacheDir, name))
}
return result, nil
}
+44 -44
View File
@@ -9,7 +9,7 @@ import (
"time"
)
// writePoolFile ?????????????????????????
// writePoolFile ??????????????? mtime ???????
func writePoolFile(t *testing.T, path string, size int, modTime time.Time) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
@@ -39,42 +39,38 @@ func mustExist(t *testing.T, path string) {
}
}
// currentVariantPath ????????????????.<??x<??q<??>.img??
// ??????????????????????????
// currentVariantPath ???????????????????
// <64 ????>.<?>x<?>q<??>.img
func currentVariantPath(dir, keyChar string, width, height, quality int) string {
key := strings.Repeat(keyChar, sha256HexLength)
return filepath.Join(dir, fmt.Sprintf("%s.%dx%dq%d.img", key, width, height, quality))
}
// TestPruneImageCachePoolsOriginalTTL ????????????????????
// ?? TTL ??????????????????????
func TestPruneImageCachePoolsOriginalTTL(t *testing.T) {
// TestPruneImageCachePoolsKeepsOldFilesUnderQuota ????????????
// ???????????????????????? / TTL ????
func TestPruneImageCachePoolsKeepsOldFilesUnderQuota(t *testing.T) {
imagesDir := t.TempDir()
now := time.Now()
stale := filepath.Join(imagesDir, imageOriginalCacheSubdir, "stale-original")
fresh := filepath.Join(imagesDir, imageOriginalCacheSubdir, "fresh-original")
thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "b", 480, 600, 80)
writePoolFile(t, stale, 400, now.Add(-8*24*time.Hour))
writePoolFile(t, fresh, 400, now.Add(-1*time.Hour))
// ??????????TTL ????
writePoolFile(t, thumb, 400, now.Add(-8*24*time.Hour))
oldOriginal := filepath.Join(imagesDir, imageOriginalCacheSubdir, "old-original")
oldThumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "b", 480, 600, 80)
writePoolFile(t, oldOriginal, 400, now.Add(-90*24*time.Hour))
writePoolFile(t, oldThumb, 400, now.Add(-90*24*time.Hour))
pools := ImageCachePools(imagesDir, 0, 7*24*time.Hour)
pools := ImageCachePools(imagesDir, 0)
res, err := PruneImageCachePools(pools, 100*1024*1024)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 1 {
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
if res.DeletedFiles != 0 {
t.Fatalf("deleted files = %d, want 0 (age must not trigger deletion)", res.DeletedFiles)
}
mustNotExist(t, stale)
mustExist(t, fresh)
mustExist(t, thumb)
mustExist(t, oldOriginal)
mustExist(t, oldThumb)
}
// TestPruneImageCachePoolsEvictsOriginalsBeforeDerived ????????????
// ????????????????????
// ????????????????
func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) {
imagesDir := t.TempDir()
now := time.Now()
@@ -82,13 +78,13 @@ func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) {
original := filepath.Join(imagesDir, imageOriginalCacheSubdir, "original")
rendition := filepath.Join(imagesDir, imageRenditionCacheSubdir, "rendition")
thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "c", 480, 600, 80)
// ?????????????????
// ?????????????????????????
writePoolFile(t, original, 1024*1024, now.Add(-72*time.Hour))
writePoolFile(t, rendition, 1024*1024, now.Add(-2*time.Hour))
writePoolFile(t, thumb, 1024*1024, now.Add(-1*time.Hour))
pools := ImageCachePools(imagesDir, 0, 0)
// ?? 3MB????2.4MB -> ????? 1.92MB??
pools := ImageCachePools(imagesDir, 0)
// ?? 3MB??? 2.4MB -> ??? 1.92MB ???
res, err := PruneImageCachePools(pools, 2*1024*1024+400*1024)
if err != nil {
t.Fatalf("prune failed: %v", err)
@@ -104,8 +100,8 @@ func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) {
}
}
// TestPruneImageCachePoolsOriginalQuotaKeepsDerived ????????????
// ??????????????
// TestPruneImageCachePoolsOriginalQuotaKeepsDerived ???????????
// ????????????
func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) {
imagesDir := t.TempDir()
now := time.Now()
@@ -117,8 +113,8 @@ func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) {
writePoolFile(t, newOriginal, 800, now.Add(-1*time.Hour))
writePoolFile(t, thumb, 800, now.Add(-2*time.Hour))
// ???? 1000 ??????????1600 -> ????800????? 1 ????
pools := ImageCachePools(imagesDir, 1000, 0)
// ???? 1000??? 1600 ?? -> ??? 800??? 1 ???????
pools := ImageCachePools(imagesDir, 1000)
res, err := PruneImageCachePools(pools, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
@@ -131,24 +127,26 @@ func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) {
mustExist(t, thumb)
}
// TestPruneImageCachePoolsCleansFailMarkers ????????????????
// ????????????????
// TestPruneImageCachePoolsCleansFailMarkers ???????????????
// ???????????????????????????????????
func TestPruneImageCachePoolsCleansFailMarkers(t *testing.T) {
imagesDir := t.TempDir()
now := time.Now()
entry := filepath.Join(imagesDir, imageOriginalCacheSubdir, "entry")
entryMarker := entry + ".fail"
kept := filepath.Join(imagesDir, imageOriginalCacheSubdir, "kept")
keptMarker := kept + ".fail"
orphanMarker := filepath.Join(imagesDir, imageOriginalCacheSubdir, "orphan.fail")
freshMarker := filepath.Join(imagesDir, imageOriginalCacheSubdir, "fresh.fail")
writePoolFile(t, entry, 900, now.Add(-10*time.Hour))
writePoolFile(t, entryMarker, 35, now.Add(-10*time.Hour))
writePoolFile(t, entry, 900, now.Add(-2*time.Hour))
writePoolFile(t, entryMarker, 35, now.Add(-2*time.Hour))
writePoolFile(t, kept, 300, now.Add(-1*time.Hour))
writePoolFile(t, keptMarker, 35, now.Add(-1*time.Hour))
writePoolFile(t, orphanMarker, 35, now.Add(-48*time.Hour))
writePoolFile(t, freshMarker, 35, now.Add(-1*time.Minute))
// ???? 500????900 ?????????????????
pools := ImageCachePools(imagesDir, 500, 0)
// ???? 500??? 900+300=1200 ???? mtime ????? entry ? 400?
pools := ImageCachePools(imagesDir, 500)
res, err := PruneImageCachePools(pools, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
@@ -158,26 +156,27 @@ func TestPruneImageCachePoolsCleansFailMarkers(t *testing.T) {
}
mustNotExist(t, entry)
mustNotExist(t, entryMarker)
mustExist(t, kept)
mustExist(t, keptMarker)
mustNotExist(t, orphanMarker)
mustExist(t, freshMarker)
}
// TestPruneImageCachePoolsSkipsTemporaryFiles ????????????????
// TestPruneImageCachePoolsSkipsTemporaryFiles ??????????????
// ?????????????????
func TestPruneImageCachePoolsSkipsTemporaryFiles(t *testing.T) {
imagesDir := t.TempDir()
tmp := filepath.Join(imagesDir, imageOriginalCacheSubdir, "img-1234.tmp")
writePoolFile(t, tmp, 4096, time.Now().Add(-48*time.Hour))
pools := ImageCachePools(imagesDir, 1, 0)
pools := ImageCachePools(imagesDir, 1)
if _, err := PruneImageCachePools(pools, 1); err != nil {
t.Fatalf("prune failed: %v", err)
}
mustExist(t, tmp)
}
// TestPruneImageCachePoolsDropsLegacyResizeVariants ????????.img????
// ?????????????????????????????????????
// TestPruneImageCachePoolsDropsLegacyResizeVariants ?????? .img ??
// ???????????????????????????
func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) {
imagesDir := t.TempDir()
resized := filepath.Join(imagesDir, imageResizeCacheSubdir)
@@ -188,7 +187,7 @@ func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) {
writePoolFile(t, current, 400, time.Now())
writePoolFile(t, other, 400, time.Now())
pools := ImageCachePools(imagesDir, 0, 0)
pools := ImageCachePools(imagesDir, 0)
res, err := PruneImageCachePools(pools, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
@@ -201,8 +200,8 @@ func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) {
}
}
// TestPruneImageCachePoolsCountsLegacyFlatLayout ??????images/ ????
// ?????????????????
// TestPruneImageCachePoolsCountsLegacyFlatLayout ???????images/ ???
// ???????????????????????
func TestPruneImageCachePoolsCountsLegacyFlatLayout(t *testing.T) {
imagesDir := t.TempDir()
legacy := filepath.Join(imagesDir, "legacy-flat-cache")
@@ -210,7 +209,8 @@ func TestPruneImageCachePoolsCountsLegacyFlatLayout(t *testing.T) {
writePoolFile(t, legacy, 900, time.Now().Add(-30*24*time.Hour))
writePoolFile(t, thumb, 900, time.Now().Add(-30*24*time.Hour))
pools := ImageCachePools(imagesDir, 0, 24*time.Hour)
// ???? 500??????????????????????????
pools := ImageCachePools(imagesDir, 500)
res, err := PruneImageCachePools(pools, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
+107 -5
View File
@@ -134,7 +134,7 @@ func TestPruneImageCache_ZeroOrNegativeLimit(t *testing.T) {
}
}
func TestSchedulerJobCleanImageCache(t *testing.T) {
func TestSchedulerJobCleanCache(t *testing.T) {
cacheRoot := t.TempDir()
imagesDir := filepath.Join(cacheRoot, "images")
if err := os.MkdirAll(imagesDir, 0o750); err != nil {
@@ -148,15 +148,117 @@ func TestSchedulerJobCleanImageCache(t *testing.T) {
scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, cacheRoot)
// Set limit to 1MB; our file is 2MB -> should be pruned
scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
return ImageCachePolicy{TotalBytes: 1 * 1024 * 1024}
scheduler.SetCacheCleanupPolicyProvider(func() CacheCleanupPolicy {
return CacheCleanupPolicy{TotalBytes: 1 * 1024 * 1024}
})
if err := scheduler.jobCleanImageCache(context.Background()); err != nil {
t.Fatalf("jobCleanImageCache failed: %v", err)
if err := scheduler.jobCleanCache(context.Background()); err != nil {
t.Fatalf("jobCleanCache failed: %v", err)
}
if _, err := os.Stat(f); !os.IsNotExist(err) {
t.Fatalf("expected file to be pruned, got err=%v", err)
}
}
// TestPruneCacheEvictionOrder 验证总量超限时严格按
// hls → images → library-covers 的顺序淘汰,且 reader-audio 不参与。
func TestPruneCacheEvictionOrder(t *testing.T) {
cacheRoot := t.TempDir()
now := time.Now()
audio := filepath.Join(cacheRoot, "reader-audio", "old.mp3")
hls := filepath.Join(cacheRoot, "hls", "job", "seg.ts")
image := filepath.Join(cacheRoot, "images", "originals", "poster.jpg")
cover := filepath.Join(cacheRoot, "library-covers", "cover.jpg")
for _, p := range []string{audio, hls, image, cover} {
writePoolFile(t, p, 1024*1024, now.Add(-time.Hour))
}
// 计入总量的只有 hls+images+cover = 3MB,上限 2MB -> 回落到 1.6MB:
// 按顺序删 hls、images 两个文件即可;reader-audio 与封面都应保留。
res, err := PruneCache(cacheRoot, 2*1024*1024, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 2 {
t.Fatalf("deleted files = %d, want 2", res.DeletedFiles)
}
mustExist(t, audio)
mustNotExist(t, hls)
mustNotExist(t, image)
mustExist(t, cover)
if _, ok := res.FreedByPool["reader-audio"]; ok {
t.Fatalf("reader-audio must not be part of the global pool, got %+v", res.FreedByPool)
}
if res.FreedByPool["hls"] != 1024*1024 || res.FreedByPool["images"] != 1024*1024 {
t.Fatalf("unexpected per-pool stats: %+v", res.FreedByPool)
}
}
// TestPruneCacheLeavesReaderAudioAlone reader-audio 有独立的 1GB 写入时淘汰,
// 不参与总容量统计:即使它单独就超过全局上限,全局任务也不应删它。
func TestPruneCacheLeavesReaderAudioAlone(t *testing.T) {
cacheRoot := t.TempDir()
audio := filepath.Join(cacheRoot, "reader-audio", "book", "chapter.mp3")
writePoolFile(t, audio, 4*1024*1024, time.Now().Add(-time.Hour))
res, err := PruneCache(cacheRoot, 1*1024*1024, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 0 {
t.Fatalf("deleted files = %d, want 0", res.DeletedFiles)
}
if res.TotalBytesBefore != 0 {
t.Fatalf("reader-audio should not count toward total, got %d bytes", res.TotalBytesBefore)
}
mustExist(t, audio)
}
// TestPruneCacheAppliesOriginalsSubQuota 验证总量没超时,图片原图子配额仍然生效。
func TestPruneCacheAppliesOriginalsSubQuota(t *testing.T) {
cacheRoot := t.TempDir()
now := time.Now()
oldOriginal := filepath.Join(cacheRoot, "images", "originals", "old.jpg")
newOriginal := filepath.Join(cacheRoot, "images", "originals", "new.jpg")
derived := currentVariantPath(filepath.Join(cacheRoot, "images", "resize"), "d", 480, 600, 80)
writePoolFile(t, oldOriginal, 800, now.Add(-2*time.Hour))
writePoolFile(t, newOriginal, 800, now.Add(-time.Hour))
writePoolFile(t, derived, 800, now.Add(-time.Hour))
// 总量上限很大(不触发全局淘汰),原图子配额 1000B -> 删到 800B。
res, err := PruneCache(cacheRoot, 100*1024*1024, 1000)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 1 {
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
}
mustNotExist(t, oldOriginal)
mustExist(t, newOriginal)
mustExist(t, derived)
}
// TestPruneCacheOldestFirstWithinPool 验证同一池内先删最旧的文件。
func TestPruneCacheOldestFirstWithinPool(t *testing.T) {
cacheRoot := t.TempDir()
now := time.Now()
oldHLS := filepath.Join(cacheRoot, "hls", "job-a", "seg1.ts")
newHLS := filepath.Join(cacheRoot, "hls", "job-b", "seg1.ts")
writePoolFile(t, oldHLS, 1024*1024, now.Add(-2*time.Hour))
writePoolFile(t, newHLS, 1024*1024, now.Add(-time.Minute))
// 上限 1.25MB -> 回落水位 1MB:删掉最旧的 1MB 后正好达标。
res, err := PruneCache(cacheRoot, 1280*1024, 0)
if err != nil {
t.Fatalf("prune failed: %v", err)
}
if res.DeletedFiles != 1 {
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
}
mustNotExist(t, oldHLS)
mustExist(t, newHLS)
}
+56 -5
View File
@@ -8,8 +8,10 @@ import (
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
@@ -130,14 +132,24 @@ type RespBase struct {
// doJSON 执行 HTTP 请求并解析为统一响应;带 AccessToken(access=true 时)。
func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map[string]string, access bool, retries int, uas ...string) (*RespBase, error) {
executor := c.executor
if executor == nil {
executor = GetGlobalExecutor()
}
ua := ""
if len(uas) > 0 {
ua = uas[0]
}
return c.doJSONWithBuilder(ctx, func() (*http.Request, error) {
return c.buildRequestWithUA(ctx, method, rawURL, form, access, ua)
}, access, retries)
}
// doJSONWithBuilder 与 doJSON 共用同一套限流、重试、令牌刷新与错误封装逻辑,
// 差别只在请求构造方式:urlencoded(doJSON)、multipart(doAuthMultipart)。
// 云下载(offline)系列接口要求 multipart/form-data,若各写一套请求循环,
// 限流熔断与 token 轮转的处理迟早会漂移。
func (c *OpenClient) doJSONWithBuilder(ctx context.Context, build func() (*http.Request, error), access bool, retries int) (*RespBase, error) {
executor := c.executor
if executor == nil {
executor = GetGlobalExecutor()
}
var lastErr error
for attempt := 0; attempt <= retries; attempt++ {
@@ -146,7 +158,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
return nil, err
}
req, err := c.buildRequestWithUA(ctx, method, rawURL, form, access, ua)
req, err := build()
if err != nil {
return nil, err
}
@@ -293,6 +305,45 @@ func (c *OpenClient) buildRequestWithUA(ctx context.Context, method, rawURL stri
return req, nil
}
// buildMultipartRequest 构造 multipart/form-data 请求。
// 115 云下载(offline)系列写接口要求 multipart/form-data,与其它接口的
// urlencoded 表单不同;这里只支持纯文本字段(不含文件),种子文件走上传接口。
func (c *OpenClient) buildMultipartRequest(ctx context.Context, method, rawURL string, fields map[string]string) (*http.Request, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
// 固定字段顺序,便于抓包比对与排障(map 遍历顺序随机)。
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if err := w.WriteField(k, fields[k]); err != nil {
return nil, err
}
}
if err := w.Close(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, strings.ToUpper(method), rawURL, &buf)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", DefaultUA)
req.Header.Set("Content-Type", w.FormDataContentType())
if accessToken := c.currentAccessToken(); accessToken != "" {
req.Header.Set("Authorization", "Bearer "+accessToken)
}
return req, nil
}
// doAuthMultipart 以 multipart/form-data 发起需要 access_token 的请求。
func (c *OpenClient) doAuthMultipart(ctx context.Context, method, rawURL string, fields map[string]string, retries int) (*RespBase, error) {
return c.doJSONWithBuilder(ctx, func() (*http.Request, error) {
return c.buildMultipartRequest(ctx, method, rawURL, fields)
}, true, retries)
}
// doAuthJSON 带 AccessToken 的业务请求。
func (c *OpenClient) doAuthJSON(ctx context.Context, method, rawURL string, form map[string]string, retries int) (*RespBase, error) {
return c.doJSON(ctx, method, rawURL, form, true, retries)
+303
View File
@@ -0,0 +1,303 @@
// 115 开放平台「云下载」(离线下载)API 封装。
//
// 文档(仓库内离线副本):115doc/115开放平台/API列表/云下载/
//
// 能力边界(已核对官方文档,勿凭客户端印象假设):
// - add_task_urls 支持 HTTP(S)/FTP/磁力/电驴,一次最多 115 条,无「选择文件」参数;
// - 选择文件只能走 BT 路径:torrent(解析已上传种子)→ add_task_bt(wanted 传索引);
// - torrent 的入参是「种子文件在 115 的 SHA1 + 提取码」,不接受 magnet。
//
// 本文件只做协议封装:不碰数据库、不做编排、不判断业务状态,方便单测。
package cloud115
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// 云下载接口路径。
const (
OfflinePathAddTaskURLs = "/open/offline/add_task_urls"
OfflinePathTorrent = "/open/offline/torrent"
OfflinePathAddTaskBT = "/open/offline/add_task_bt"
OfflinePathTaskList = "/open/offline/get_task_list"
OfflinePathQuotaInfo = "/open/offline/get_quota_info"
OfflinePathDelTask = "/open/offline/del_task"
OfflinePathClearTask = "/open/offline/clear_task"
)
// OfflineMaxURLsPerBatch 是 add_task_urls 单批链接上限(官方文档:1000011 超 115 个)。
const OfflineMaxURLsPerBatch = 115
// 云下载业务错误码。
const (
OfflineCodeOK = 0
OfflineCodeQuotaUsedUp = 1000012 // 云下载配额已用完
OfflineCodeStorageFull = 91006 // 存储空间不足
OfflineCodeTorrentMissing = 20018 // 文件不存在或已删除(种子无效)
OfflineCodeTooManyURLs = 1000011 // 链接数量超过 115 个
OfflineCodeParamError = 990002 // 参数错误
OfflineCodeOperationFailed = 980004 // 操作失败,可重试
)
// 云下载任务状态(get_task_list → data.tasks[].status)。
const (
OfflineTaskStatusFailed = -1 // 下载失败
OfflineTaskStatusAllocating = 0 // 分配中
OfflineTaskStatusDownloading = 1 // 下载中
OfflineTaskStatusDone = 2 // 下载成功
)
// 清空任务的类型(clear_task → flag)。
const (
OfflineClearDone = 0
OfflineClearAll = 1
OfflineClearFailed = 2
OfflineClearRunning = 3
OfflineClearDoneWithFile = 4
OfflineClearAllWithFile = 5
)
// OfflineTaskURLResult 是 add_task_urls 返回的逐条结果。
type OfflineTaskURLResult struct {
State bool `json:"state"`
Code int `json:"code"`
Message string `json:"message"`
InfoHash string `json:"info_hash"`
URL string `json:"url"`
}
// OfflineTorrentFile 是种子解析结果中的单个文件。
type OfflineTorrentFile struct {
Size int64 `json:"size"`
Path string `json:"path"`
Wanted int `json:"wanted"` // 115 认为该文件是否默认选中
}
// OfflineTorrentInfo 是 /offline/torrent 的解析结果。
type OfflineTorrentInfo struct {
FileSize int64 `json:"file_size"`
Name string `json:"torrent_name"`
FileCount int `json:"file_count"`
InfoHash string `json:"info_hash"`
Files []OfflineTorrentFile `json:"torrent_filelist"`
}
// OfflineTask 是云下载任务列表中的一条任务。
type OfflineTask struct {
InfoHash string `json:"info_hash"`
AddTime int64 `json:"add_time"`
PercentDone int `json:"percentDone"` // 注意:官方字段是驼峰,其余为 snake_case
Size int64 `json:"size"`
Name string `json:"name"`
LastUpdate int64 `json:"last_update"`
FileID string `json:"file_id"`
DeleteFileID string `json:"delete_file_id"`
Status int `json:"status"`
URL string `json:"url"`
WpPathID string `json:"wp_path_id"`
Def2 int `json:"def2"`
PlayLong int `json:"play_long"`
CanAppeal int `json:"can_appeal"`
}
// OfflineTaskPage 是分页任务列表。
type OfflineTaskPage struct {
Page int `json:"page"`
PageCount int `json:"page_count"`
Count int `json:"count"`
Tasks []OfflineTask `json:"tasks"`
}
// OfflineQuotaPackage 是某一类配额的使用情况。
type OfflineQuotaPackage struct {
Surplus int `json:"surplus"`
Used int `json:"used"`
Count int `json:"count"`
Name string `json:"name"`
ExpireInfo []OfflineQuotaExpire `json:"expire_info"`
}
// OfflineQuotaExpire 是配额过期明细。
type OfflineQuotaExpire struct {
Surplus int `json:"surplus"`
ExpireTime int64 `json:"expire_time"`
}
// OfflineQuota 是云下载配额总览。
type OfflineQuota struct {
Package []OfflineQuotaPackage `json:"package"`
Count int `json:"count"`
Surplus int `json:"surplus"`
Used int `json:"used"`
}
// OfflineBTRequest 是 add_task_bt 的请求参数。
type OfflineBTRequest struct {
InfoHash string
Wanted string // 半角逗号分隔的文件索引;官方文档:「可以为 0,但不能为空」
SavePath string // 相对 wp_path_id 的保存路径,可为空
TorrentSha1 string
PickCode string
WpPathID string
}
// AddTaskURLs 批量添加链接任务(HTTP/FTP/磁力/电驴),单批 ≤115 条。
// 返回逐条结果:整体请求成功但个别链接失败是常态,调用方必须逐条判断 State。
// 注意:data[].info_hash 只在受理成功时才有值,链接类任务可能为空。
func (c *OpenClient) AddTaskURLs(ctx context.Context, urls []string, wpPathID string) ([]OfflineTaskURLResult, error) {
cleaned := make([]string, 0, len(urls))
for _, u := range urls {
if u = strings.TrimSpace(u); u != "" {
cleaned = append(cleaned, u)
}
}
if len(cleaned) == 0 {
return nil, fmt.Errorf("115: 云下载链接为空")
}
if len(cleaned) > OfflineMaxURLsPerBatch {
return nil, fmt.Errorf("115: 单批云下载链接最多 %d 条,当前 %d 条", OfflineMaxURLsPerBatch, len(cleaned))
}
if wpPathID = strings.TrimSpace(wpPathID); wpPathID == "" {
wpPathID = "0"
}
fields := map[string]string{
"urls": strings.Join(cleaned, "\n"),
"wp_path_id": wpPathID,
}
resp, err := c.doAuthMultipart(ctx, "POST", ProAPIBase+OfflinePathAddTaskURLs, fields, 1)
if err != nil {
return nil, err
}
var results []OfflineTaskURLResult
if err := unmarshalOfflineData(resp, &results); err != nil {
return nil, err
}
return results, nil
}
// ParseTorrent 解析已上传到 115 的 BT 种子文件。
// torrentSha1/pickCode 必须来自该账号空间内的种子文件(上传接口返回值)。
func (c *OpenClient) ParseTorrent(ctx context.Context, torrentSha1, pickCode string) (*OfflineTorrentInfo, error) {
torrentSha1 = strings.TrimSpace(torrentSha1)
pickCode = strings.TrimSpace(pickCode)
if torrentSha1 == "" || pickCode == "" {
return nil, fmt.Errorf("115: 解析种子需要 torrent_sha1 与 pick_code")
}
fields := map[string]string{
"torrent_sha1": torrentSha1,
"pick_code": pickCode,
}
resp, err := c.doAuthMultipart(ctx, "POST", ProAPIBase+OfflinePathTorrent, fields, 1)
if err != nil {
return nil, err
}
var info OfflineTorrentInfo
if err := unmarshalOfflineData(resp, &info); err != nil {
return nil, err
}
return &info, nil
}
// AddTaskBT 根据已解析的种子添加云下载 BT 任务。
// wanted 传文件索引(半角逗号分隔);官方文档允许传 "0",但语义未在文档中写明,
// 因此调用方应优先传解析结果推导出的索引列表(见 service 层)。
func (c *OpenClient) AddTaskBT(ctx context.Context, req OfflineBTRequest) error {
infoHash := strings.TrimSpace(req.InfoHash)
torrentSha1 := strings.TrimSpace(req.TorrentSha1)
pickCode := strings.TrimSpace(req.PickCode)
if infoHash == "" || torrentSha1 == "" || pickCode == "" {
return fmt.Errorf("115: 添加 BT 任务缺少 info_hash/torrent_sha1/pick_code")
}
wanted := strings.TrimSpace(req.Wanted)
if wanted == "" {
wanted = "0"
}
wpPathID := strings.TrimSpace(req.WpPathID)
if wpPathID == "" {
wpPathID = "0"
}
fields := map[string]string{
"info_hash": infoHash,
"wanted": wanted,
"save_path": strings.TrimSpace(req.SavePath),
"torrent_sha1": torrentSha1,
"pick_code": pickCode,
"wp_path_id": wpPathID,
}
if _, err := c.doAuthMultipart(ctx, "POST", ProAPIBase+OfflinePathAddTaskBT, fields, 1); err != nil {
return err
}
return nil
}
// GetOfflineTaskList 分页获取云下载任务列表(page 从 1 开始)。
func (c *OpenClient) GetOfflineTaskList(ctx context.Context, page int) (*OfflineTaskPage, error) {
if page < 1 {
page = 1
}
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+OfflinePathTaskList, map[string]string{
"page": fmt.Sprint(page),
}, 1)
if err != nil {
return nil, err
}
var list OfflineTaskPage
if err := unmarshalOfflineData(resp, &list); err != nil {
return nil, err
}
return &list, nil
}
// GetOfflineQuota 获取云下载配额信息。
func (c *OpenClient) GetOfflineQuota(ctx context.Context) (*OfflineQuota, error) {
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+OfflinePathQuotaInfo, nil, 1)
if err != nil {
return nil, err
}
var quota OfflineQuota
if err := unmarshalOfflineData(resp, &quota); err != nil {
return nil, err
}
return &quota, nil
}
// DeleteOfflineTask 删除指定云下载任务(按 info_hash)。
// delSourceFile=true 时同时删除已下载的源文件。
func (c *OpenClient) DeleteOfflineTask(ctx context.Context, infoHash string, delSourceFile bool) error {
infoHash = strings.TrimSpace(infoHash)
if infoHash == "" {
return fmt.Errorf("115: 删除云下载任务需要 info_hash")
}
del := "0"
if delSourceFile {
del = "1"
}
fields := map[string]string{
"info_hash": infoHash,
"del_source_file": del,
}
_, err := c.doAuthMultipart(ctx, "POST", ProAPIBase+OfflinePathDelTask, fields, 1)
return err
}
// ClearOfflineTasks 按类型清空云下载任务。
func (c *OpenClient) ClearOfflineTasks(ctx context.Context, flag int) error {
fields := map[string]string{"flag": fmt.Sprint(flag)}
_, err := c.doAuthMultipart(ctx, "POST", ProAPIBase+OfflinePathClearTask, fields, 1)
return err
}
// unmarshalOfflineData 解析 data 字段。115 的 /offline/* 接口在 state=true 时
// 仍可能给出空 data(例如 add_task_bt 成功时 data 为空数组),调用方需容忍零值。
func unmarshalOfflineData(resp *RespBase, out any) error {
if resp == nil || len(resp.Data) == 0 {
return nil
}
if err := json.Unmarshal(resp.Data, out); err != nil {
return fmt.Errorf("115: 解析云下载响应失败:%w", err)
}
return nil
}
+170
View File
@@ -0,0 +1,170 @@
package cloud115
import (
"context"
"net/http"
"strings"
"testing"
)
// newOfflineTestClient 构造一个指向 mock server 的客户端。
// 令牌直接写入内存字段,避免走文件/数据库。
func newOfflineTestClient(accessToken string) *OpenClient {
return NewOpenClient("test-app", accessToken, "")
}
func TestAddTaskURLsUsesMultipartAndBatches(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != OfflinePathAddTaskURLs {
t.Errorf("unexpected path %s", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Errorf("unexpected method %s", r.Method)
}
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data") {
t.Errorf("expected multipart content-type, got %q", ct)
}
if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") || len(got) <= len("Bearer ") {
t.Errorf("unexpected authorization %q", got)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("parse multipart: %v", err)
}
if got := r.FormValue("wp_path_id"); got != "12345" {
t.Errorf("wp_path_id = %q, want 12345", got)
}
urls := r.FormValue("urls")
if want := "magnet:?xt=urn:btih:AAA\ned2k://|file|x|1|H|/"; urls != want {
t.Errorf("urls = %q, want %q", urls, want)
}
_, _ = w.Write([]byte(`{"state":true,"code":0,"data":[{"state":true,"code":0,"info_hash":"ABC","url":"magnet:?xt=urn:btih:AAA"}]}`))
})
client := newOfflineTestClient("token-abc")
res, err := client.AddTaskURLs(context.Background(), []string{
" magnet:?xt=urn:btih:AAA ",
"ed2k://|file|x|1|H|/",
}, "12345")
if err != nil {
t.Fatalf("AddTaskURLs: %v", err)
}
if len(res) != 1 || !res[0].State || res[0].InfoHash != "ABC" {
t.Fatalf("unexpected result %+v", res)
}
}
func TestAddTaskURLsRejectsOverLimitWithoutRequest(t *testing.T) {
called := false
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
called = true
})
urls := make([]string, OfflineMaxURLsPerBatch+1)
for i := range urls {
urls[i] = "https://example.com/a"
}
if _, err := newOfflineTestClient("t").AddTaskURLs(context.Background(), urls, "1"); err == nil {
t.Fatal("expected error for over-limit batch")
}
if called {
t.Fatal("must not call 115 when batch exceeds the documented limit")
}
}
func TestAddTaskURLsSurfacesBusinessError(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"state":false,"code":1000012,"message":"云下载配额已用完"}`))
})
_, err := newOfflineTestClient("t").AddTaskURLs(context.Background(), []string{"magnet:?xt=urn:btih:AAA"}, "1")
if err == nil {
t.Fatal("expected business error")
}
apiErr, ok := err.(*OpenAPIError)
if !ok {
t.Fatalf("expected *OpenAPIError, got %T", err)
}
if apiErr.Code != OfflineCodeQuotaUsedUp {
t.Fatalf("code = %d, want %d", apiErr.Code, OfflineCodeQuotaUsedUp)
}
}
func TestParseTorrentDecodesFileList(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != OfflinePathTorrent {
t.Errorf("unexpected path %s", r.URL.Path)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("parse multipart: %v", err)
}
if r.FormValue("torrent_sha1") != "SHA1" || r.FormValue("pick_code") != "PICK" {
t.Errorf("unexpected form: %v", r.MultipartForm.Value)
}
_, _ = w.Write([]byte(`{"state":true,"code":0,"data":{
"file_size":1024,"torrent_name":"Demo","file_count":2,"info_hash":"HASH",
"torrent_filelist":[{"size":512,"path":"a.mkv","wanted":1},{"size":512,"path":"b.srt","wanted":0}]}}`))
})
info, err := newOfflineTestClient("t").ParseTorrent(context.Background(), "SHA1", "PICK")
if err != nil {
t.Fatalf("ParseTorrent: %v", err)
}
if info.InfoHash != "HASH" || info.Name != "Demo" || len(info.Files) != 2 {
t.Fatalf("unexpected info %+v", info)
}
if info.Files[0].Path != "a.mkv" || info.Files[0].Size != 512 {
t.Fatalf("unexpected file %+v", info.Files[0])
}
}
func TestAddTaskBTRequiresSeedFields(t *testing.T) {
called := false
mockAPI(t, func(w http.ResponseWriter, r *http.Request) { called = true })
client := newOfflineTestClient("t")
if err := client.AddTaskBT(context.Background(), OfflineBTRequest{InfoHash: "H"}); err == nil {
t.Fatal("expected validation error")
}
if called {
t.Fatal("must validate locally before calling 115")
}
}
func TestGetOfflineTaskListParsesCamelCasePercentDone(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != OfflinePathTaskList {
t.Errorf("unexpected path %s", r.URL.Path)
}
if r.URL.Query().Get("page") != "2" {
t.Errorf("page = %q, want 2", r.URL.Query().Get("page"))
}
_, _ = w.Write([]byte(`{"state":true,"code":0,"data":{"page":2,"page_count":3,"count":30,
"tasks":[{"info_hash":"H","percentDone":42,"size":100,"name":"n","status":1,"url":"u","wp_path_id":"9"}]}}`))
})
page, err := newOfflineTestClient("t").GetOfflineTaskList(context.Background(), 2)
if err != nil {
t.Fatalf("GetOfflineTaskList: %v", err)
}
if page.Page != 2 || page.PageCount != 3 || len(page.Tasks) != 1 {
t.Fatalf("unexpected page %+v", page)
}
// 官方字段是驼峰 percentDone:写成 snake_case 会永远 0,这里必须锁死。
if page.Tasks[0].PercentDone != 42 {
t.Fatalf("percentDone = %d, want 42", page.Tasks[0].PercentDone)
}
if page.Tasks[0].Status != OfflineTaskStatusDownloading {
t.Fatalf("status = %d", page.Tasks[0].Status)
}
}
func TestGetOfflineQuota(t *testing.T) {
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != OfflinePathQuotaInfo {
t.Errorf("unexpected path %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"state":true,"code":0,"data":{"package":[{"surplus":3,"used":1,"count":4,"name":"p"}],"count":4,"surplus":3,"used":1}}`))
})
q, err := newOfflineTestClient("t").GetOfflineQuota(context.Background())
if err != nil {
t.Fatalf("GetOfflineQuota: %v", err)
}
if q.Surplus != 3 || q.Count != 4 || len(q.Package) != 1 {
t.Fatalf("unexpected quota %+v", q)
}
}
+433
View File
@@ -0,0 +1,433 @@
// 115 云下载(离线下载)服务:提交链接、轮询 115 任务、驱动后处理管线
// (STRM 增量同步 → 媒体库扫描 → 自动刮削)。
//
// 职责边界:
// - 115 协议细节在 internal/service/cloud115/offline.go;
// - 本服务只做编排与状态机,不直接写 .strm 文件、不直接解析媒体;
// - 115 客户端必须经 StrmService.Open115Client 取得,禁止自建(见该方法注释)。
package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// 云下载设置键(存于 Setting 表,cloud_download.* 前缀)。
const (
CloudDownloadSettingPollInterval = "cloud_download.poll_interval_sec"
CloudDownloadSettingSyncDebounce = "cloud_download.sync_debounce_sec"
CloudDownloadSettingAutoScrape = "cloud_download.auto_scrape"
CloudDownloadSettingTimeoutHours = "cloud_download.task_timeout_hours"
CloudDownloadSettingTorrentMaxMB = "cloud_download.torrent_max_mb"
CloudDownloadSettingMaxPages = "cloud_download.max_pages"
)
// 默认值与取值范围。
const (
cloudDownloadDefaultPollInterval = 60
cloudDownloadDefaultDebounce = 30
cloudDownloadDefaultTimeoutHours = 72
cloudDownloadDefaultTorrentMaxMB = 20
cloudDownloadDefaultMaxPages = 10
cloudDownloadMinPollInterval = 30
cloudDownloadMaxPollInterval = 600
cloudDownloadMaxDebounce = 300
// cloudDownloadPipelineTick 是后处理推进的扫描周期(去抖窗口以秒计,
// 15s 足够把「等窗口结束」的延迟控制在可接受范围)。
cloudDownloadPipelineTick = 15 * time.Second
// cloudDownloadTorrentTempPrefix 是种子临时文件前缀(上传后立即删除)。
cloudDownloadTorrentTempPrefix = "mebox-clouddl-"
)
// CloudDownloadService 提供云下载任务的全生命周期管理。
type CloudDownloadService struct {
log *zap.Logger
repo *repository.Container
strm *StrmService
scanner *ScannerService
scraper *ScraperService
stopOnce sync.Once
stopCh chan struct{}
baseCtx context.Context
// 去抖状态:同一同步目录在窗口内的多次完成合并成一次同步。
debounceMu sync.Mutex
lastTrigger map[string]time.Time
}
// NewCloudDownloadService 构造云下载服务。
// strm/scanner/scraper 必须已构造完成(见 service_builder 的装配顺序)。
func NewCloudDownloadService(
log *zap.Logger,
repo *repository.Container,
strm *StrmService,
scanner *ScannerService,
scraper *ScraperService,
) *CloudDownloadService {
return &CloudDownloadService{
log: log,
repo: repo,
strm: strm,
scanner: scanner,
scraper: scraper,
stopCh: make(chan struct{}),
baseCtx: context.Background(),
lastTrigger: map[string]time.Time{},
}
}
// Start 启动后处理循环,并把上次进程退出时停在中间阶段的任务重置为待推进。
func (s *CloudDownloadService) Start(ctx context.Context) {
s.baseCtx = ctx
if s.repo != nil && s.repo.CloudDownload != nil {
if n, err := s.repo.CloudDownload.ResetStaleStages(ctx); err != nil {
s.log.Warn("reset stale cloud download stages failed", zap.Error(err))
} else if n > 0 {
s.log.Info("cloud download stages reset after restart", zap.Int64("count", n))
}
}
helper.Go(s.log, "cloudDownload.pipelineLoop", func() {
ticker := time.NewTicker(cloudDownloadPipelineTick)
defer ticker.Stop()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.runPipelineOnce(s.baseCtx)
}
}
})
}
// Stop 停止后处理循环。
func (s *CloudDownloadService) Stop() {
s.stopOnce.Do(func() { close(s.stopCh) })
}
// ─── 设置读取 ─────────────────────────────────────────────────────────────────
func (s *CloudDownloadService) settingInt(key string, fallback, min, max int) int {
if s.repo == nil || s.repo.Setting == nil {
return fallback
}
raw, err := s.repo.Setting.Get(context.Background(), key)
if err != nil || strings.TrimSpace(raw) == "" {
return fallback
}
v, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return fallback
}
if v < min {
return min
}
if v > max {
return max
}
return v
}
func (s *CloudDownloadService) settingBool(key string, fallback bool) bool {
if s.repo == nil || s.repo.Setting == nil {
return fallback
}
raw, err := s.repo.Setting.Get(context.Background(), key)
if err != nil {
return fallback
}
switch strings.ToLower(strings.TrimSpace(raw)) {
case "":
return fallback
case "1", "true", "yes", "on", "enabled":
return true
default:
return false
}
}
// pollInterval 轮询 115 任务列表的间隔。
func (s *CloudDownloadService) pollInterval() time.Duration {
sec := s.settingInt(CloudDownloadSettingPollInterval, cloudDownloadDefaultPollInterval,
cloudDownloadMinPollInterval, cloudDownloadMaxPollInterval)
return time.Duration(sec) * time.Second
}
// debounceWindow 同一同步目录的完成去抖窗口。
func (s *CloudDownloadService) debounceWindow() time.Duration {
sec := s.settingInt(CloudDownloadSettingSyncDebounce, cloudDownloadDefaultDebounce, 0, cloudDownloadMaxDebounce)
return time.Duration(sec) * time.Second
}
// autoScrapeEnabled 是否在同步完成后自动刮削。
func (s *CloudDownloadService) autoScrape() bool {
return s.settingBool(CloudDownloadSettingAutoScrape, true)
}
// taskTimeout 下载超时判定。
func (s *CloudDownloadService) taskTimeout() time.Duration {
hours := s.settingInt(CloudDownloadSettingTimeoutHours, cloudDownloadDefaultTimeoutHours, 1, 24*30)
return time.Duration(hours) * time.Hour
}
// torrentMaxBytes 种子上传大小上限。
func (s *CloudDownloadService) torrentMaxBytes() int64 {
mb := s.settingInt(CloudDownloadSettingTorrentMaxMB, cloudDownloadDefaultTorrentMaxMB, 1, 200)
return int64(mb) << 20
}
// maxPollPages 单轮轮询最多翻页数。
func (s *CloudDownloadService) maxPollPages() int {
return s.settingInt(CloudDownloadSettingMaxPages, cloudDownloadDefaultMaxPages, 1, 100)
}
// ─── 查询与管理 ───────────────────────────────────────────────────────────────
// CloudDownloadTaskView 是列表返回的任务视图(附带展示用的派生字段)。
type CloudDownloadTaskView struct {
model.CloudDownloadTask
SyncPathName string `json:"sync_path_name,omitempty"`
RemoteDisplayPath string `json:"remote_display_path,omitempty"`
LocalPath string `json:"local_path,omitempty"`
}
// CloudDownloadList 是分页列表结果。
type CloudDownloadList struct {
Items []CloudDownloadTaskView `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// List 分页查询云下载任务,并补充同步目录的展示信息。
func (s *CloudDownloadService) List(ctx context.Context, q repository.CloudDownloadQuery) (*CloudDownloadList, error) {
rows, total, err := s.repo.CloudDownload.List(ctx, q)
if err != nil {
return nil, err
}
paths := map[string]*model.StrmSyncPath{}
items := make([]CloudDownloadTaskView, 0, len(rows))
for i := range rows {
v := CloudDownloadTaskView{CloudDownloadTask: rows[i]}
if p, ok := paths[rows[i].SyncPathID]; ok {
if p != nil {
v.SyncPathName, v.RemoteDisplayPath, v.LocalPath = p.Name, p.RemoteDisplayPath, p.LocalPath
}
} else if s.repo.StrmSyncPath != nil {
p, err := s.repo.StrmSyncPath.FindByID(ctx, rows[i].SyncPathID)
if err == nil {
paths[rows[i].SyncPathID] = p
if p != nil {
v.SyncPathName, v.RemoteDisplayPath, v.LocalPath = p.Name, p.RemoteDisplayPath, p.LocalPath
}
}
}
items = append(items, v)
}
page := q.Page
if page < 1 {
page = 1
}
pageSize := q.PageSize
if pageSize <= 0 {
pageSize = 20
}
return &CloudDownloadList{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// Get 按 ID 取单条任务。
func (s *CloudDownloadService) Get(ctx context.Context, id string) (*model.CloudDownloadTask, error) {
t, err := s.repo.CloudDownload.FindByID(ctx, id)
if err != nil {
return nil, err
}
if t == nil {
return nil, errors.New("云下载任务不存在")
}
return t, nil
}
// Cancel 取消任务:先尽力删除 115 侧任务,再把本地状态置为已取消。
// 115 已成功下载的任务无法取消(文件已入库),只能删除记录。
func (s *CloudDownloadService) Cancel(ctx context.Context, id string) error {
t, err := s.Get(ctx, id)
if err != nil {
return err
}
if isCloudDownloadTerminal(t.Status) {
return errors.New("任务已结束,无法取消")
}
if strings.TrimSpace(t.InfoHash) != "" {
client, cerr := s.strm.Open115Client(ctx, t.AccountID)
if cerr != nil {
s.log.Warn("cloud download cancel: open 115 client failed",
zap.String("task_id", t.ID), zap.Error(cerr))
} else if derr := client.DeleteOfflineTask(ctx, t.InfoHash, false); derr != nil {
// 远端删除失败不阻断本地取消:否则用户永远清不掉一条僵尸记录。
s.log.Warn("cloud download cancel: delete remote task failed",
zap.String("task_id", t.ID), zap.String("info_hash", t.InfoHash), zap.Error(derr))
}
}
return s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
"status": model.CloudDownloadCanceled,
"stage": model.CloudDownloadStageNA,
"stage_error": "任务已取消",
})
}
// Retry 重新提交失败任务:链接类重新走提交接口,BT 类用已保存的种子参数重提。
func (s *CloudDownloadService) Retry(ctx context.Context, id string) error {
t, err := s.Get(ctx, id)
if err != nil {
return err
}
if t.Status != model.CloudDownloadFailed {
return errors.New("只有失败的任务可以重试")
}
if t.SourceType == model.CloudDownloadSourceTorrent {
if strings.TrimSpace(t.InfoHash) == "" || strings.TrimSpace(t.TorrentSha1) == "" || strings.TrimSpace(t.PickCode) == "" {
return errors.New("该种子任务的提交参数不完整,请重新上传 .torrent 文件")
}
client, err := s.strm.Open115Client(ctx, t.AccountID)
if err != nil {
return err
}
if err := client.AddTaskBT(ctx, cloud115.OfflineBTRequest{
InfoHash: t.InfoHash,
Wanted: "0",
TorrentSha1: t.TorrentSha1,
PickCode: t.PickCode,
WpPathID: t.WpPathID,
}); err != nil {
return err
}
return s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
"status": model.CloudDownloadSubmitted,
"error": "",
"retry_count": t.RetryCount + 1,
})
}
if strings.TrimSpace(t.SourceURL) == "" {
return errors.New("该任务缺少原始链接,无法重试")
}
// 复用提交链路:新建一条记录会让历史重复,这里直接把原记录复位再提交。
if err := s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
"status": model.CloudDownloadQueued,
"error": "",
"percent": 0,
"retry_count": t.RetryCount + 1,
"stage": model.CloudDownloadStagePending,
"stage_error": "",
}); err != nil {
return err
}
res := s.submitLinks(ctx, t.AccountID, t.SyncPathID, t.WpPathID, []cloudDownloadParsedLink{{
SourceType: t.SourceType,
URL: t.SourceURL,
Key: t.SourceKey,
}}, t.ID)
if len(res.Items) == 1 && !res.Items[0].OK {
return errors.New(res.Items[0].Error)
}
return nil
}
// RetryPipeline 重做失败的后处理阶段(不重新下载)。
func (s *CloudDownloadService) RetryPipeline(ctx context.Context, id string) error {
t, err := s.Get(ctx, id)
if err != nil {
return err
}
if t.Status != model.CloudDownloadSucceeded {
return errors.New("只有下载成功的任务可以重做后处理")
}
if t.Stage != model.CloudDownloadStageFailed && t.Stage != model.CloudDownloadStageNA {
return errors.New("该任务的后处理未处于失败状态")
}
if err := s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
"stage": model.CloudDownloadStagePending,
"stage_error": "",
}); err != nil {
return err
}
helper.Go(s.log, "cloudDownload.retryPipeline", func() {
s.runPipelineOnce(s.baseCtx)
})
return nil
}
// Delete 删除任务记录。活跃任务必须先取消,避免留下无人追问的 115 任务。
func (s *CloudDownloadService) Delete(ctx context.Context, id string) error {
t, err := s.Get(ctx, id)
if err != nil {
return err
}
if !isCloudDownloadTerminal(t.Status) {
return errors.New("任务进行中,请先取消再删除")
}
return s.repo.CloudDownload.Delete(ctx, id)
}
// ClearFinished 清空失败/已取消的任务。
func (s *CloudDownloadService) ClearFinished(ctx context.Context) (int64, error) {
return s.repo.CloudDownload.ClearFinished(ctx)
}
// Quota 查询当前账号的云下载配额。
func (s *CloudDownloadService) Quota(ctx context.Context, accountID string) (*cloud115.OfflineQuota, error) {
client, err := s.strm.Open115Client(ctx, accountID)
if err != nil {
return nil, err
}
return client.GetOfflineQuota(ctx)
}
// ─── 工具 ─────────────────────────────────────────────────────────────────────
// isCloudDownloadTerminal 判断任务是否已到终态。
func isCloudDownloadTerminal(status string) bool {
switch status {
case model.CloudDownloadSucceeded, model.CloudDownloadFailed, model.CloudDownloadCanceled:
return true
default:
return false
}
}
// offlineErrorMessage 把 115 错误码翻译成可操作的中文提示。
func offlineErrorMessage(code int, message string) string {
switch code {
case cloud115.OfflineCodeQuotaUsedUp:
return "115 云下载配额已用尽,请在 115 端获取更多配额后重试"
case cloud115.OfflineCodeStorageFull:
return "115 存储空间不足"
case cloud115.OfflineCodeTorrentMissing:
return "115 找不到该种子文件(可能已被清理),请重新上传 .torrent"
case cloud115.OfflineCodeTooManyURLs:
return "单批链接超过 115 条上限"
case cloud115.OfflineCodeParamError:
return "115 拒绝了提交参数:" + message
case cloud115.OfflineCodeOperationFailed:
return "115 操作失败,请稍后重试"
}
if strings.TrimSpace(message) != "" {
return message
}
return fmt.Sprintf("115 返回错误码 %d", code)
}
+121
View File
@@ -0,0 +1,121 @@
// 云下载输入解析:把用户粘贴的多行文本拆成可提交的链接,并生成轮询匹配键。
//
// 支持的来源(与 115 /open/offline/add_task_urls 能力一致):
// - 磁力:magnet:?xt=urn:btih:<hex40 或 base32>
// - 电驴:ed2k://|file|...
// - HTTP(S) / FTP 直链
//
// 只做「字符串层面」的识别,绝不主动请求这些 URL(无 SSRF 面)。
package service
import (
"net/url"
"strings"
)
// cloudDownloadParsedLink 是一条解析后的待提交链接。
type cloudDownloadParsedLink struct {
SourceType string // model.CloudDownloadSource*
URL string // 原始(trim 后)链接
Key string // 去重与轮询匹配键:BT 类为大写 info_hash,其余为链接本身
InfoHash string // 仅 BT 类有值
}
// cloudDownloadSkipped 是一条被跳过的原始输入。
type cloudDownloadSkipped struct {
Raw string `json:"raw"`
Reason string `json:"reason"`
}
// parseCloudDownloadLinks 解析多行输入。
// 返回去重后的有效链接与被跳过的行(用于前端逐条反馈,而不是笼统报错)。
func parseCloudDownloadLinks(raw []string) ([]cloudDownloadParsedLink, []cloudDownloadSkipped) {
out := make([]cloudDownloadParsedLink, 0, len(raw))
skipped := make([]cloudDownloadSkipped, 0)
seen := make(map[string]bool, len(raw))
for _, line := range raw {
// 允许多行文本整体粘贴:按换行再拆一次。
for _, part := range strings.Split(line, "\n") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
link, reason := parseCloudDownloadLink(part)
if reason != "" {
skipped = append(skipped, cloudDownloadSkipped{Raw: part, Reason: reason})
continue
}
if seen[link.Key] {
skipped = append(skipped, cloudDownloadSkipped{Raw: part, Reason: "重复链接(本批已存在)"})
continue
}
seen[link.Key] = true
out = append(out, link)
}
}
return out, skipped
}
// parseCloudDownloadLink 识别单条链接;reason 非空表示不支持。
func parseCloudDownloadLink(raw string) (cloudDownloadParsedLink, string) {
lower := strings.ToLower(raw)
switch {
case strings.HasPrefix(lower, "magnet:"):
hash := magnetInfoHash(raw)
if hash == "" {
return cloudDownloadParsedLink{}, "磁力链接缺少有效的 btih 哈希"
}
return cloudDownloadParsedLink{
SourceType: "magnet",
URL: raw,
Key: hash,
InfoHash: hash,
}, ""
case strings.HasPrefix(lower, "ed2k://"):
return cloudDownloadParsedLink{
SourceType: "ed2k",
URL: raw,
Key: raw,
}, ""
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
return cloudDownloadParsedLink{
SourceType: "http",
URL: raw,
Key: raw,
}, ""
case strings.HasPrefix(lower, "ftp://"):
return cloudDownloadParsedLink{
SourceType: "ftp",
URL: raw,
Key: raw,
}, ""
default:
return cloudDownloadParsedLink{}, "不支持的链接类型(仅支持磁力 / ed2k / http(s) / ftp)"
}
}
// magnetInfoHash 从磁力链接中提取大写化的 btih 哈希。
// 兼容 hex(40 字符)与 base32(32 字符)两种写法;无法识别时返回空串。
func magnetInfoHash(link string) string {
u, err := url.Parse(strings.TrimSpace(link))
if err != nil {
return ""
}
for _, xt := range u.Query()["xt"] {
const prefix = "urn:btih:"
if len(xt) <= len(prefix) || !strings.EqualFold(xt[:len(prefix)], prefix) {
continue
}
hash := strings.TrimSpace(xt[len(prefix):])
// 去掉可能存在的 &dn= 之类残留(少数客户端拼接不规范)。
if idx := strings.IndexAny(hash, "&?#"); idx >= 0 {
hash = hash[:idx]
}
if len(hash) != 40 && len(hash) != 32 {
continue
}
return strings.ToUpper(hash)
}
return ""
}
+285
View File
@@ -0,0 +1,285 @@
// 云下载后处理管线:下载成功 → STRM 增量同步 → 媒体库扫描 → 自动刮削。
//
// 与 STRM 同步的耦合方式:
// - 这里只负责「发起同步」,不知道同步内部怎么走;
// - StrmService.finishSync 通过 SetSyncFinishHook 回调 onStrmSyncFinished,
// 由后者把 syncing 状态推进到 scanning/done。
//
// 去抖:bench 上常见一场「一次提交 20 条链接」,如果每条完成都触发一次全树
// 同步(walk115Flat 是整树自适应拉取),115 限流与本地 IO 都会被打满。
// 因此同一同步目录在 debounce 窗口内只发起一次同步,其余任务停在 waiting_sync。
package service
import (
"context"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
)
// runPipelineOnce 推进所有待处理任务。由 Start 里的 ticker 周期调用,也可手动触发。
func (s *CloudDownloadService) runPipelineOnce(ctx context.Context) {
if s.repo == nil || s.repo.CloudDownload == nil {
return
}
candidates, err := s.repo.CloudDownload.ListPipelineCandidates(ctx)
if err != nil {
s.log.Warn("cloud download pipeline: list candidates failed", zap.Error(err))
return
}
if len(candidates) == 0 {
return
}
groups := map[string][]model.CloudDownloadTask{}
for _, t := range candidates {
groups[t.SyncPathID] = append(groups[t.SyncPathID], t)
}
for syncPathID, tasks := range groups {
s.advanceSyncPath(ctx, syncPathID, tasks)
}
}
// advanceSyncPath 推进一个同步目录下的待处理任务。
func (s *CloudDownloadService) advanceSyncPath(ctx context.Context, syncPathID string, tasks []model.CloudDownloadTask) {
path, err := s.repo.StrmSyncPath.FindByID(ctx, syncPathID)
if err != nil {
s.log.Warn("cloud download pipeline: load sync path failed",
zap.String("path_id", syncPathID), zap.Error(err))
return
}
if path == nil || !path.Enabled || path.Provider != model.StrmProvider115 {
s.failStage(ctx, tasks, "同步目录不存在、已禁用或不是 115 网盘目录", model.CloudDownloadStageNA)
return
}
// 同步进行中:本轮只标记等待,避免把 StartSync 的「正在同步中」错误打成失败。
if s.strm != nil && s.strm.IsSyncRunning(syncPathID) {
s.markWaiting(ctx, tasks)
return
}
// 去抖窗口内不重复发起同步。
if !s.debounceElapsed(syncPathID) {
s.markWaiting(ctx, tasks)
return
}
if s.strm == nil {
s.failStage(ctx, tasks, "STRM 同步服务不可用", model.CloudDownloadStageFailed)
return
}
if err := s.strm.StartSync(ctx, syncPathID); err != nil {
// 最常见的竞态:刚判断完不忙,另一个人(或 cron)抢先启动。
if strings.Contains(err.Error(), "正在同步中") {
s.markWaiting(ctx, tasks)
return
}
s.failStage(ctx, tasks, "触发 STRM 同步失败:"+err.Error(), model.CloudDownloadStageFailed)
return
}
s.noteTrigger(syncPathID)
for _, t := range tasks {
_ = s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
"stage": model.CloudDownloadStageSyncing,
"stage_error": "",
})
}
}
// onStrmSyncFinished 是 StrmService 的同步完成回调。
// 只处理「因云下载而发起且当前处于 syncing」的任务:手动/Cron 同步也会触发
// 这个回调,没有云下载任务时直接返回。
func (s *CloudDownloadService) onStrmSyncFinished(ctx context.Context, syncPathID, status string) {
if s.repo == nil || s.repo.CloudDownload == nil {
return
}
tasks, err := s.repo.CloudDownload.ListBySyncPathAndStage(ctx, syncPathID, model.CloudDownloadStageSyncing)
if err != nil {
s.log.Warn("cloud download pipeline: list syncing tasks failed",
zap.String("path_id", syncPathID), zap.Error(err))
return
}
if len(tasks) == 0 {
return
}
if status != model.StrmSyncRecordDone {
for _, t := range tasks {
_ = s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
"stage": model.CloudDownloadStageFailed,
"stage_error": "STRM 同步" + strmStatusLabel(status),
})
}
// 同步失败也要记一次触发时间,给用户一个冷静期,避免立即重试形成惊吓式循环。
s.noteTrigger(syncPathID)
return
}
path, err := s.repo.StrmSyncPath.FindByID(ctx, syncPathID)
if err != nil || path == nil {
s.failStage(ctx, tasks, "同步目录已被删除", model.CloudDownloadStageNA)
return
}
root := s.matchLibraryRoot(ctx, path.LocalPath)
if root == nil {
s.failStage(ctx, tasks, "STRM 输出目录未加入任何媒体库(本地目录:"+path.LocalPath+")", model.CloudDownloadStageNA)
return
}
for _, t := range tasks {
_ = s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
"stage": model.CloudDownloadStageScanning,
"library_id": root.LibraryID,
})
}
scanAdded, scanErr := s.scanLibrary(ctx, root.LibraryID)
if scanErr != nil {
s.failStage(ctx, tasks, "扫描媒体库失败:"+scanErr.Error(), model.CloudDownloadStageFailed)
return
}
scrapeQueued := 0
if s.autoScrape() && s.scraper != nil && s.scraper.AnyEnabled() {
// EnqueueLibrary 默认只挑 scrape_status 为空的 pending 行,
// 因此全库入队不会重刷已刮好的条目。
if n, err := s.scraper.EnqueueLibrary(ctx, root.LibraryID, ScrapeOptions{}); err != nil {
s.log.Warn("cloud download pipeline: enqueue scrape failed",
zap.String("library_id", root.LibraryID), zap.Error(err))
} else {
scrapeQueued = n
}
}
stageError := ""
if s.autoScrape() && (s.scraper == nil || !s.scraper.AnyEnabled()) {
stageError = "未配置任何刮削源,已跳过自动刮削"
}
for _, t := range tasks {
_ = s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
"stage": model.CloudDownloadStageDone,
"stage_error": stageError,
"library_id": root.LibraryID,
"scan_added": scanAdded,
"scrape_queued": scrapeQueued,
})
}
s.log.Info("cloud download pipeline finished",
zap.String("path_id", syncPathID),
zap.String("library_id", root.LibraryID),
zap.Int("tasks", len(tasks)),
zap.Int("scan_added", scanAdded),
zap.Int("scrape_queued", scrapeQueued))
}
// markWaiting 把任务置为等待同步。
func (s *CloudDownloadService) markWaiting(ctx context.Context, tasks []model.CloudDownloadTask) {
for _, t := range tasks {
if t.Stage == model.CloudDownloadStageWaitingSync {
continue
}
_ = s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
"stage": model.CloudDownloadStageWaitingSync,
"stage_error": "",
})
}
}
// failStage 把任务置为阶段失败/跳过。
func (s *CloudDownloadService) failStage(ctx context.Context, tasks []model.CloudDownloadTask, msg, stage string) {
for _, t := range tasks {
_ = s.repo.CloudDownload.UpdateFields(ctx, t.ID, map[string]any{
"stage": stage,
"stage_error": msg,
})
}
}
// matchLibraryRoot 按 STRM 本地输出目录反查媒体库根路径。
// 命中条件:路径相同,或 localPath 位于 root.Path 之下(同一块盘的子目录也算)。
func (s *CloudDownloadService) matchLibraryRoot(ctx context.Context, localPath string) *model.LibraryRoot {
localPath = strings.TrimSpace(localPath)
if localPath == "" || s.repo.Library == nil {
return nil
}
roots, err := s.repo.Library.ListEnabledRoots(ctx)
if err != nil {
s.log.Warn("cloud download pipeline: list library roots failed", zap.Error(err))
return nil
}
local := filepath.Clean(localPath)
for i := range roots {
root := roots[i]
candidate := filepath.Clean(strings.TrimSpace(root.Path))
if candidate == "" {
continue
}
if sameLibraryPath(candidate, local) {
return &root
}
// 子目录判断也走同一套路径归一化(Windows 盘符映射/容器前缀)。
withSep := candidate
if !strings.HasSuffix(withSep, string(filepath.Separator)) {
withSep += string(filepath.Separator)
}
if strings.HasPrefix(local, withSep) || strings.HasPrefix(cleanPathForVolumeMapping(local), cleanPathForVolumeMapping(withSep)) {
return &root
}
}
return nil
}
// scanLibrary 扫描媒体库并返回新增条目数。
func (s *CloudDownloadService) scanLibrary(ctx context.Context, libraryID string) (int, error) {
if s.scanner == nil {
return 0, nil
}
res, err := s.scanner.ScanLibrary(ctx, libraryID)
if err != nil {
return 0, err
}
if res == nil {
return 0, nil
}
return res.Added, nil
}
// debounceElapsed 判断距上次触发是否已过去一个去抖窗口。
func (s *CloudDownloadService) debounceElapsed(syncPathID string) bool {
window := s.debounceWindow()
if window <= 0 {
return true
}
s.debounceMu.Lock()
defer s.debounceMu.Unlock()
last, ok := s.lastTrigger[syncPathID]
if !ok {
return true
}
return time.Since(last) >= window
}
// noteTrigger 记录本次触发时间。
func (s *CloudDownloadService) noteTrigger(syncPathID string) {
s.debounceMu.Lock()
s.lastTrigger[syncPathID] = time.Now()
s.debounceMu.Unlock()
}
// strmStatusLabel 把同步记录状态翻译成中文。
func strmStatusLabel(status string) string {
switch status {
case model.StrmSyncRecordFailed:
return "失败"
case model.StrmSyncRecordCanceled:
return "被取消"
case model.StrmSyncRecordDone:
return "完成"
default:
return status
}
}
+149
View File
@@ -0,0 +1,149 @@
// 云下载轮询:把 115 任务列表的状态映射回本地任务表。
//
// 设计约束:
// - 没有活跃任务时不发起任何 115 请求(避免无意义配额与限流风险);
// - 按账号分组,一个账号一轮只翻页一次,供该账号下所有任务复用;
// - 只做状态映射,不触发后处理(后处理由 pipeline 负责,两者解耦)。
package service
import (
"context"
"strings"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// PollOnce 执行一轮 115 任务状态同步。导出以便测试与手动触发。
func (s *CloudDownloadService) PollOnce(ctx context.Context) error {
active, err := s.repo.CloudDownload.ListByStatuses(ctx, []string{
model.CloudDownloadSubmitted,
model.CloudDownloadDownloading,
})
if err != nil {
return err
}
if len(active) == 0 {
return nil
}
// 按账号分组:同一账号只拉一次任务列表。
byAccount := map[string][]model.CloudDownloadTask{}
for _, t := range active {
byAccount[t.AccountID] = append(byAccount[t.AccountID], t)
}
for accountID, tasks := range byAccount {
s.pollAccount(ctx, accountID, tasks)
}
return nil
}
// pollAccount 拉取单个账号的任务列表并回写本地任务状态。
func (s *CloudDownloadService) pollAccount(ctx context.Context, accountID string, tasks []model.CloudDownloadTask) {
client, err := s.strm.Open115Client(ctx, accountID)
if err != nil {
// 账号被禁用/删除时不改任务状态:等用户处理账号,任务保留可重试。
s.log.Warn("cloud download poll: open 115 client failed",
zap.String("account_id", accountID), zap.Error(err))
return
}
byHash := map[string]cloud115.OfflineTask{}
byURL := map[string]cloud115.OfflineTask{}
pages := s.maxPollPages()
for page := 1; page <= pages; page++ {
list, err := client.GetOfflineTaskList(ctx, page)
if err != nil {
s.log.Warn("cloud download poll: list tasks failed",
zap.String("account_id", accountID), zap.Int("page", page), zap.Error(err))
break
}
if list == nil {
break
}
for _, t := range list.Tasks {
if h := strings.ToUpper(strings.TrimSpace(t.InfoHash)); h != "" {
byHash[h] = t
}
if u := strings.TrimSpace(t.URL); u != "" {
byURL[u] = t
}
}
if list.PageCount <= page || len(list.Tasks) == 0 {
break
}
}
now := time.Now()
timeout := s.taskTimeout()
for _, t := range tasks {
offline := matchOfflineTask(t, byHash, byURL)
fields := map[string]any{"last_poll_at": now}
if offline == nil {
// 115 侧暂未出现(受理有延迟)或已不在列表(被清理)。
// 只在超时后才判失败,避免把「刚提交」误判成失败。
if !t.CreatedAt.IsZero() && now.Sub(t.CreatedAt) > timeout {
fields["status"] = model.CloudDownloadFailed
fields["error"] = "下载超时:115 侧长时间未完成(任务可能已被清理)"
}
s.applyPollFields(ctx, t.ID, fields)
continue
}
switch offline.Status {
case cloud115.OfflineTaskStatusDone:
fields["status"] = model.CloudDownloadSucceeded
fields["percent"] = 100
if offline.Size > 0 {
fields["size_bytes"] = offline.Size
}
if t.CompletedAt == nil {
fields["completed_at"] = now
}
fields["error"] = ""
case cloud115.OfflineTaskStatusFailed:
fields["status"] = model.CloudDownloadFailed
fields["error"] = "115 云下载失败(可能资源失效或无人做种)"
default: // 0 分配中 / 1 下载中
fields["status"] = model.CloudDownloadDownloading
fields["percent"] = offline.PercentDone
if offline.Size > 0 {
fields["size_bytes"] = offline.Size
}
}
s.applyPollFields(ctx, t.ID, fields)
}
}
// applyPollFields 只写轮询关心的列,绝不能整行 Update:
// 后处理线程可能在并发写 stage/scan_added 等字段。
func (s *CloudDownloadService) applyPollFields(ctx context.Context, taskID string, fields map[string]any) {
if err := s.repo.CloudDownload.UpdateFields(ctx, taskID, fields); err != nil {
s.log.Warn("cloud download poll: update task failed",
zap.String("task_id", taskID), zap.Error(err))
}
}
// matchOfflineTask 匹配 115 任务:info_hash 优先,其次原始链接。
func matchOfflineTask(t model.CloudDownloadTask, byHash, byURL map[string]cloud115.OfflineTask) *cloud115.OfflineTask {
if h := strings.ToUpper(strings.TrimSpace(t.InfoHash)); h != "" {
if item, ok := byHash[h]; ok {
return &item
}
}
for _, key := range []string{t.SourceKey, t.SourceURL} {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if item, ok := byURL[key]; ok {
return &item
}
}
return nil
}
+439
View File
@@ -0,0 +1,439 @@
// 云下载提交:校验目标、配额预检、批量提交链接、.torrent 上传解析后提交。
package service
import (
"context"
"errors"
"fmt"
"os"
"strings"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// cloudDownloadSeedDirName 是 115 上用于暂存 .torrent 的目录(两级)。
// 官方文档建议把种子放进「云下载/种子文件」,但不是硬性要求;不存在时自动创建。
var cloudDownloadSeedDirPath = []string{"云下载", "种子文件"}
// CloudDownloadSubmit 是链接提交请求。
// AccountID 允许为空:始终以同步目录绑定的账号为准,避免前端传错账号导致
// 「任务挂在 A 账号、文件落到 B 目录」这种无法自动恢复的错配。
type CloudDownloadSubmit struct {
AccountID string `json:"account_id"`
SyncPathID string `json:"sync_path_id"`
URLs []string `json:"urls"`
}
// CloudDownloadTorrentRequest 是 .torrent 提交请求(文件内容由 handler 读入)。
type CloudDownloadTorrentRequest struct {
AccountID string
SyncPathID string
FileName string
Data []byte
}
// CloudDownloadTaskResult 是单条来源的提交结果。
type CloudDownloadTaskResult struct {
TaskID string `json:"task_id,omitempty"`
Source string `json:"source"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// CloudDownloadSubmitResult 是批量提交结果:部分成功也返回 200 + 明细。
type CloudDownloadSubmitResult struct {
Accepted int `json:"accepted"`
Rejected int `json:"rejected"`
Items []CloudDownloadTaskResult `json:"items"`
Skipped []cloudDownloadSkipped `json:"skipped,omitempty"`
}
// Submit 提交一批链接任务。
func (s *CloudDownloadService) Submit(ctx context.Context, req CloudDownloadSubmit) (*CloudDownloadSubmitResult, error) {
p, err := s.resolveTarget(ctx, req.SyncPathID)
if err != nil {
return nil, err
}
links, skipped := parseCloudDownloadLinks(req.URLs)
res := &CloudDownloadSubmitResult{Skipped: skipped}
if len(links) == 0 {
for _, sk := range skipped {
res.Items = append(res.Items, CloudDownloadTaskResult{Source: sk.Raw, Error: sk.Reason})
res.Rejected++
}
return res, nil
}
if err := s.ensureQuota(ctx, p.AccountID); err != nil {
return nil, err
}
submitRes := s.submitLinks(ctx, p.AccountID, p.ID, p.RemotePath, links, "")
submitRes.Skipped = skipped
return submitRes, nil
}
// SubmitTorrent 上传 .torrent 到 115、解析后提交为 BT 任务。
//
// 本期不做「选择部分文件」:115 开放平台的磁力链路没有文件选择能力,
// .torrent 链路虽然支持 wanted,但产品上暂不提供勾选 UI(见 docs/cloud-download-plan.md)。
// 因此 wanted 传 "0"(官方文档唯一明确允许的取值),语义待真机验证。
func (s *CloudDownloadService) SubmitTorrent(ctx context.Context, req CloudDownloadTorrentRequest) (*CloudDownloadSubmitResult, error) {
p, err := s.resolveTarget(ctx, req.SyncPathID)
if err != nil {
return nil, err
}
if len(req.Data) == 0 {
return nil, errors.New("种子文件为空")
}
if int64(len(req.Data)) > s.torrentMaxBytes() {
return nil, fmt.Errorf("种子文件超过大小上限(%d MB)", s.torrentMaxBytes()>>20)
}
if err := s.ensureQuota(ctx, p.AccountID); err != nil {
return nil, err
}
client, err := s.strm.Open115Client(ctx, p.AccountID)
if err != nil {
return nil, err
}
// 1. 写入临时文件:115 上传接口只接受本地路径。
tmp, err := os.CreateTemp("", cloudDownloadTorrentTempPrefix+"*.torrent")
if err != nil {
return nil, fmt.Errorf("创建种子临时文件失败:%w", err)
}
tmpPath := tmp.Name()
defer func() { _ = os.Remove(tmpPath) }()
if _, err := tmp.Write(req.Data); err != nil {
_ = tmp.Close()
return nil, fmt.Errorf("写入种子临时文件失败:%w", err)
}
if err := tmp.Close(); err != nil {
return nil, fmt.Errorf("关闭种子临时文件失败:%w", err)
}
// 2. 上传到「云下载/种子文件」;建目录失败时回落到同步目录,避免整条链路失败。
parentCID, derr := s.ensureSeedDir(ctx, client)
if derr != nil {
s.log.Warn("cloud download: ensure seed dir failed, fallback to sync path",
zap.String("path_id", p.ID), zap.Error(derr))
parentCID = p.RemotePath
}
uploaded, err := client.Upload(ctx, tmpPath, parentCID, "", "")
if err != nil {
return nil, fmt.Errorf("上传种子到 115 失败:%w", err)
}
if strings.TrimSpace(uploaded.Sha1) == "" || strings.TrimSpace(uploaded.PickCode) == "" {
return nil, errors.New("115 上传种子未返回 sha1/pick_code")
}
// 3. 解析种子(拿 info_hash 与任务名)。
info, err := client.ParseTorrent(ctx, uploaded.Sha1, uploaded.PickCode)
if err != nil {
return nil, fmt.Errorf("解析 BT 种子失败:%w", err)
}
infoHash := strings.ToUpper(strings.TrimSpace(info.InfoHash))
if infoHash == "" {
return nil, errors.New("115 解析种子未返回 info_hash")
}
// 4. 提交 BT 任务。
if err := client.AddTaskBT(ctx, cloud115.OfflineBTRequest{
InfoHash: infoHash,
Wanted: "0",
TorrentSha1: uploaded.Sha1,
PickCode: uploaded.PickCode,
WpPathID: p.RemotePath,
}); err != nil {
return nil, fmt.Errorf("提交 BT 任务失败:%w", err)
}
// 5. 落库。
task := &model.CloudDownloadTask{
AccountID: p.AccountID,
SyncPathID: p.ID,
Provider: model.StrmProvider115,
SourceType: model.CloudDownloadSourceTorrent,
SourceURL: req.FileName,
SourceKey: infoHash,
InfoHash: infoHash,
TorrentName: info.Name,
TorrentSha1: uploaded.Sha1,
PickCode: uploaded.PickCode,
WpPathID: p.RemotePath,
Status: model.CloudDownloadSubmitted,
SizeBytes: info.FileSize,
Stage: model.CloudDownloadStagePending,
}
if err := s.repo.CloudDownload.Create(ctx, task); err != nil {
return nil, err
}
return &CloudDownloadSubmitResult{
Accepted: 1,
Items: []CloudDownloadTaskResult{{
TaskID: task.ID,
Source: req.FileName,
OK: true,
}},
}, nil
}
// resolveTarget 校验同步目录并返回它。云下载只支持 115 同步目录,
// 且远端必须是有效目录 ID(0 表示网盘根目录,落到根目录的离线文件永远扫不到)。
func (s *CloudDownloadService) resolveTarget(ctx context.Context, syncPathID string) (*model.StrmSyncPath, error) {
syncPathID = strings.TrimSpace(syncPathID)
if syncPathID == "" {
return nil, errors.New("请选择目标 STRM 同步目录")
}
p, err := s.repo.StrmSyncPath.FindByID(ctx, syncPathID)
if err != nil {
return nil, err
}
if p == nil {
return nil, errors.New("STRM 同步目录不存在")
}
if !p.Enabled {
return nil, errors.New("STRM 同步目录已禁用")
}
if p.Provider != model.StrmProvider115 {
return nil, errors.New("只有 115 网盘同步目录支持云下载")
}
if strings.TrimSpace(p.AccountID) == "" {
return nil, errors.New("该同步目录未关联 115 账号")
}
wp := strings.TrimSpace(p.RemotePath)
if wp == "" || wp == "0" {
return nil, errors.New("同步目录的远端目录无效(不能是网盘根目录)")
}
return p, nil
}
// ensureQuota 提交前的配额闸门。配额接口失败不阻断提交:
// 部分应用可能未开放该接口权限,真正的失败由 add_task_* 的错误码兜底。
func (s *CloudDownloadService) ensureQuota(ctx context.Context, accountID string) error {
client, err := s.strm.Open115Client(ctx, accountID)
if err != nil {
return err
}
quota, err := client.GetOfflineQuota(ctx)
if err != nil {
s.log.Warn("cloud download: quota probe failed, continue submit",
zap.String("account_id", accountID), zap.Error(err))
return nil
}
if quota != nil && quota.Surplus <= 0 {
return errors.New("115 云下载配额已用尽,请在 115 端获取更多配额后重试")
}
return nil
}
// submitLinks 分批提交链接,并逐条落库。
// existingTaskID 非空时表示「重试单条历史任务」,此时更新原记录而不是新建。
func (s *CloudDownloadService) submitLinks(
ctx context.Context,
accountID, syncPathID, wpPathID string,
links []cloudDownloadParsedLink,
existingTaskID string,
) *CloudDownloadSubmitResult {
res := &CloudDownloadSubmitResult{}
active := map[string]bool{}
if existingTaskID == "" {
keys := make([]string, 0, len(links))
for _, l := range links {
keys = append(keys, l.Key)
}
if dedup, err := s.repo.CloudDownload.FindActiveBySourceKeys(ctx, keys); err == nil {
active = dedup
}
filtered := make([]cloudDownloadParsedLink, 0, len(links))
for _, l := range links {
if active[l.Key] {
res.Items = append(res.Items, CloudDownloadTaskResult{
Source: l.URL,
Error: "该链接已有进行中的任务",
})
res.Rejected++
continue
}
filtered = append(filtered, l)
}
links = filtered
}
if len(links) == 0 {
return res
}
client, err := s.strm.Open115Client(ctx, accountID)
if err != nil {
for _, l := range links {
res.Items = append(res.Items, CloudDownloadTaskResult{Source: l.URL, Error: err.Error()})
res.Rejected++
s.persistSubmitFailure(ctx, accountID, syncPathID, wpPathID, l, err.Error(), existingTaskID)
}
return res
}
for start := 0; start < len(links); start += cloud115.OfflineMaxURLsPerBatch {
end := start + cloud115.OfflineMaxURLsPerBatch
if end > len(links) {
end = len(links)
}
batch := links[start:end]
urls := make([]string, 0, len(batch))
for _, l := range batch {
urls = append(urls, l.URL)
}
results, reqErr := client.AddTaskURLs(ctx, urls, wpPathID)
if reqErr != nil {
msg := reqErr.Error()
for _, l := range batch {
res.Items = append(res.Items, CloudDownloadTaskResult{Source: l.URL, Error: msg})
res.Rejected++
s.persistSubmitFailure(ctx, accountID, syncPathID, wpPathID, l, msg, existingTaskID)
}
continue
}
// 115 不保证结果顺序与入参一致:先按 url 建索引,缺失项再按顺序兜底。
byURL := make(map[string]cloud115.OfflineTaskURLResult, len(results))
for _, r := range results {
if u := strings.TrimSpace(r.URL); u != "" {
byURL[u] = r
}
}
for i, l := range batch {
r, ok := byURL[l.URL]
if !ok && i < len(results) {
r = results[i]
}
if !r.State {
msg := offlineErrorMessage(r.Code, r.Message)
res.Items = append(res.Items, CloudDownloadTaskResult{Source: l.URL, Error: msg})
res.Rejected++
s.persistSubmitFailure(ctx, accountID, syncPathID, wpPathID, l, msg, existingTaskID)
continue
}
infoHash := strings.ToUpper(strings.TrimSpace(r.InfoHash))
if infoHash == "" {
infoHash = l.InfoHash
}
key := l.Key
if infoHash != "" {
key = infoHash
}
taskID, perr := s.persistSubmitSuccess(ctx, accountID, syncPathID, wpPathID, l, key, infoHash, existingTaskID)
if perr != nil {
res.Items = append(res.Items, CloudDownloadTaskResult{Source: l.URL, Error: "115 已受理,但本地记录失败:" + perr.Error()})
res.Rejected++
continue
}
res.Items = append(res.Items, CloudDownloadTaskResult{TaskID: taskID, Source: l.URL, OK: true})
res.Accepted++
}
}
return res
}
// persistSubmitSuccess 写入一条已受理的任务,返回任务 ID。
func (s *CloudDownloadService) persistSubmitSuccess(
ctx context.Context, accountID, syncPathID, wpPathID string,
link cloudDownloadParsedLink, key, infoHash, existingTaskID string,
) (string, error) {
if existingTaskID != "" {
return existingTaskID, s.repo.CloudDownload.UpdateFields(ctx, existingTaskID, map[string]any{
"status": model.CloudDownloadSubmitted,
"error": "",
"percent": 0,
"info_hash": infoHash,
"source_key": key,
"last_poll_at": nil,
"stage": model.CloudDownloadStagePending,
"stage_error": "",
})
}
task := &model.CloudDownloadTask{
AccountID: accountID,
SyncPathID: syncPathID,
Provider: model.StrmProvider115,
SourceType: link.SourceType,
SourceURL: link.URL,
SourceKey: key,
InfoHash: infoHash,
WpPathID: wpPathID,
Status: model.CloudDownloadSubmitted,
Stage: model.CloudDownloadStagePending,
}
if err := s.repo.CloudDownload.Create(ctx, task); err != nil {
return "", err
}
return task.ID, nil
}
// persistSubmitFailure 记录一条提交失败的任务,供用户在列表里看到原因并重试。
func (s *CloudDownloadService) persistSubmitFailure(
ctx context.Context, accountID, syncPathID, wpPathID string,
link cloudDownloadParsedLink, msg, existingTaskID string,
) {
if existingTaskID != "" {
_ = s.repo.CloudDownload.UpdateFields(ctx, existingTaskID, map[string]any{
"status": model.CloudDownloadFailed,
"error": msg,
})
return
}
task := &model.CloudDownloadTask{
AccountID: accountID,
SyncPathID: syncPathID,
Provider: model.StrmProvider115,
SourceType: link.SourceType,
SourceURL: link.URL,
SourceKey: link.Key,
InfoHash: link.InfoHash,
WpPathID: wpPathID,
Status: model.CloudDownloadFailed,
Error: msg,
Stage: model.CloudDownloadStagePending,
}
if err := s.repo.CloudDownload.Create(ctx, task); err != nil {
s.log.Warn("cloud download: persist failed task failed", zap.Error(err))
}
}
// ensureSeedDir 返回 115 上「云下载/种子文件」目录的 cid,不存在则创建。
func (s *CloudDownloadService) ensureSeedDir(ctx context.Context, client *cloud115.OpenClient) (string, error) {
parent := "0"
for _, name := range cloudDownloadSeedDirPath {
cid, err := s.findOrCreateDir(ctx, client, parent, name)
if err != nil {
return "", err
}
parent = cid
}
return parent, nil
}
// findOrCreateDir 在 parentCID 下按名称查找子目录,未命中则创建。
// 先查后建的原因:115 的 folder/add 对已存在同名目录的行为文档未定义,
// 直接创建可能得到重复目录或错误。
func (s *CloudDownloadService) findOrCreateDir(ctx context.Context, client *cloud115.OpenClient, parentCID, name string) (string, error) {
const pageSize = 1150
offset := 0
for {
files, _, err := client.GetFsList(ctx, parentCID, offset, pageSize)
if err != nil {
return "", err
}
for _, f := range files {
if f.Category == cloud115.TypeDir && strings.EqualFold(strings.TrimSpace(f.FileName), name) {
return f.FileId, nil
}
}
if len(files) < pageSize {
break
}
offset += len(files)
}
return client.MkDir(ctx, parentCID, name)
}
+220
View File
@@ -0,0 +1,220 @@
package service
import (
"context"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestParseCloudDownloadLinks(t *testing.T) {
links, skipped := parseCloudDownloadLinks([]string{
" magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=x ",
"ed2k://|file|a.mkv|100|0123456789ABCDEF0123456789ABCDEF|/",
"https://example.com/a.mkv",
"ftp://example.com/b.mkv",
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567", // 与第一条重复
"not-a-link",
"",
"magnet:?xt=urn:btih:tooshort", // 非法哈希
})
if len(links) != 4 {
t.Fatalf("links = %d, want 4: %+v", len(links), links)
}
if links[0].SourceType != model.CloudDownloadSourceMagnet {
t.Errorf("first source type = %q", links[0].SourceType)
}
if links[0].Key != "0123456789ABCDEF0123456789ABCDEF01234567" {
t.Errorf("magnet key = %q, want uppercase info hash", links[0].Key)
}
if links[1].SourceType != model.CloudDownloadSourceED2K {
t.Errorf("second source type = %q", links[1].SourceType)
}
if links[2].SourceType != model.CloudDownloadSourceHTTP {
t.Errorf("third source type = %q", links[2].SourceType)
}
if links[3].SourceType != model.CloudDownloadSourceFTP {
t.Errorf("fourth source type = %q", links[3].SourceType)
}
// 空行不算错误,只有重复链接/不支持的类型/非法哈希会被记为跳过。
if len(skipped) != 3 {
t.Fatalf("skipped = %d, want 3: %+v", len(skipped), skipped)
}
}
func TestParseCloudDownloadLinksAcceptsMultilinePaste(t *testing.T) {
links, _ := parseCloudDownloadLinks([]string{
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\nhttps://example.com/a.mkv\n",
})
if len(links) != 2 {
t.Fatalf("links = %d, want 2", len(links))
}
}
func TestMagnetInfoHashSupportsBase32(t *testing.T) {
got := magnetInfoHash("magnet:?xt=urn:btih:ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
if got != "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" {
t.Fatalf("base32 hash = %q", got)
}
if got := magnetInfoHash("magnet:?dn=x"); got != "" {
t.Fatalf("missing btih should yield empty, got %q", got)
}
}
func TestOfflineErrorMessage(t *testing.T) {
cases := []struct {
code int
want string
}{
{1000012, "115 云下载配额已用尽,请在 115 端获取更多配额后重试"},
{91006, "115 存储空间不足"},
{20018, "115 找不到该种子文件(可能已被清理),请重新上传 .torrent"},
{1000011, "单批链接超过 115 条上限"},
{990002, "115 拒绝了提交参数:bad"},
{980004, "115 操作失败,请稍后重试"},
}
for _, c := range cases {
if got := offlineErrorMessage(c.code, "bad"); got != c.want {
t.Errorf("code %d => %q, want %q", c.code, got, c.want)
}
}
if got := offlineErrorMessage(1234, "原始信息"); got != "原始信息" {
t.Errorf("unknown code should keep 115 message, got %q", got)
}
}
func TestCloudDownloadDebounce(t *testing.T) {
db := newServiceTestDB(t)
repos := repository.New(db)
svc := NewCloudDownloadService(zap.NewNop(), repos, nil, nil, nil)
if !svc.debounceElapsed("p1") {
t.Fatal("first trigger must be allowed")
}
svc.noteTrigger("p1")
if svc.debounceElapsed("p1") {
t.Fatal("second trigger inside window must be suppressed")
}
// 另一个同步目录互不影响。
if !svc.debounceElapsed("p2") {
t.Fatal("debounce must be per sync path")
}
// 窗口置 0 表示不合并。
if err := repos.Setting.Set(context.Background(), CloudDownloadSettingSyncDebounce, "0"); err != nil {
t.Fatal(err)
}
if !svc.debounceElapsed("p1") {
t.Fatal("zero window should disable debounce")
}
}
func TestMatchLibraryRoot(t *testing.T) {
db := newServiceTestDB(t)
repos := repository.New(db)
ctx := context.Background()
lib := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
root := &model.LibraryRoot{LibraryID: "tmp", Path: "/media/movies", Enabled: true}
if err := repos.Library.CreateWithRoots(ctx, lib, []model.LibraryRoot{*root}); err != nil {
t.Fatal(err)
}
svc := NewCloudDownloadService(zap.NewNop(), repos, nil, nil, nil)
if got := svc.matchLibraryRoot(ctx, "/media/movies"); got == nil || got.LibraryID != lib.ID {
t.Fatalf("exact path should match library, got %+v", got)
}
if got := svc.matchLibraryRoot(ctx, "/media/movies/2024"); got == nil || got.LibraryID != lib.ID {
t.Fatalf("sub directory should match library, got %+v", got)
}
if got := svc.matchLibraryRoot(ctx, "/media/tv"); got != nil {
t.Fatalf("unrelated path must not match, got %+v", got)
}
}
func TestIsCloudDownloadTerminal(t *testing.T) {
terminal := []string{model.CloudDownloadSucceeded, model.CloudDownloadFailed, model.CloudDownloadCanceled}
for _, s := range terminal {
if !isCloudDownloadTerminal(s) {
t.Errorf("%s should be terminal", s)
}
}
for _, s := range []string{model.CloudDownloadQueued, model.CloudDownloadSubmitted, model.CloudDownloadDownloading} {
if isCloudDownloadTerminal(s) {
t.Errorf("%s should not be terminal", s)
}
}
}
func TestResolveTargetRejectsInvalidSyncPath(t *testing.T) {
db := newServiceTestDB(t)
repos := repository.New(db)
ctx := context.Background()
svc := NewCloudDownloadService(zap.NewNop(), repos, nil, nil, nil)
if _, err := svc.resolveTarget(ctx, ""); err == nil {
t.Fatal("empty sync path should fail")
}
if _, err := svc.resolveTarget(ctx, "missing"); err == nil {
t.Fatal("unknown sync path should fail")
}
// 远端目录为 0(网盘根目录):离线文件永远扫不到,必须拒绝。
bad := &model.StrmSyncPath{
Name: "根目录", AccountID: "acc", Provider: model.StrmProvider115,
RemotePath: "0", LocalPath: "/media/root", Enabled: true,
}
if err := repos.StrmSyncPath.Create(ctx, bad); err != nil {
t.Fatal(err)
}
if _, err := svc.resolveTarget(ctx, bad.ID); err == nil {
t.Fatal("remote root path should be rejected")
}
// 非 115 同步目录不支持云下载。
cd2 := &model.StrmSyncPath{
Name: "CD2", AccountID: "acc", Provider: model.StrmProviderCloudDrive,
RemotePath: "/movies", LocalPath: "/media/cd2", Enabled: true,
}
if err := repos.StrmSyncPath.Create(ctx, cd2); err != nil {
t.Fatal(err)
}
if _, err := svc.resolveTarget(ctx, cd2.ID); err == nil {
t.Fatal("non-115 provider should be rejected")
}
}
func TestCloudDownloadSettingsDefaults(t *testing.T) {
db := newServiceTestDB(t)
svc := NewCloudDownloadService(zap.NewNop(), repository.New(db), nil, nil, nil)
if got := svc.pollInterval(); got != 60*time.Second {
t.Errorf("default poll interval = %v", got)
}
if got := svc.debounceWindow(); got != 30*time.Second {
t.Errorf("default debounce = %v", got)
}
if !svc.autoScrape() {
t.Error("auto scrape should default to true")
}
if got := svc.taskTimeout(); got != 72*time.Hour {
t.Errorf("default timeout = %v", got)
}
if got := svc.torrentMaxBytes(); got != 20<<20 {
t.Errorf("default torrent max = %d", got)
}
if got := svc.maxPollPages(); got != 10 {
t.Errorf("default max pages = %d", got)
}
// 越界值必须被夹紧,避免把轮询间隔配成 1 秒打爆 115 限流。
if err := repository.New(db).Setting.Set(context.Background(), CloudDownloadSettingPollInterval, "1"); err != nil {
t.Fatal(err)
}
if got := svc.pollInterval(); got != 30*time.Second {
t.Errorf("clamped poll interval = %v, want 30s", got)
}
}
@@ -0,0 +1,254 @@
package service
import (
"context"
"fmt"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// resetEmbyInvalThrottle 清掉全局失效节流,让测试能断言「这一次调用到底有
// 没有真的清缓存」。测试结束后恢复原值,避免影响同包其他用例。
func resetEmbyInvalThrottle(t *testing.T) {
t.Helper()
embyInvalMu.Lock()
previous := embyInvalLast
embyInvalLast = time.Time{}
embyInvalMu.Unlock()
t.Cleanup(func() {
embyInvalMu.Lock()
embyInvalLast = previous
embyInvalMu.Unlock()
})
}
func newCachedTestEmbyService(t *testing.T) *EmbyService {
t.Helper()
svc := newTestEmbyService(t)
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
t.Cleanup(func() { _ = cache.Close() })
return svc.SetRuntimeCache(cache)
}
// TestRecordProgressKeepsEmbyPayloadCache 覆盖首页加载慢的核心回归点:
// 播放期间客户端每 5-10 秒上报一次进度,如果每次都清空 media:emby:*,
// 客户端刷新首页时 73 个媒体库的 Latest 会全部回源重建(生产环境实测
// 首批 8-25s)。普通进度上报必须保留载荷缓存;只有「已看完」翻转才失效。
func TestRecordProgressKeepsEmbyPayloadCache(t *testing.T) {
svc := newCachedTestEmbyService(t)
const mediaID = "ep-1"
media := model.Media{
Base: model.Base{ID: mediaID},
Title: "第 1 集",
Path: `/media/tv/Show/Season 01/S01E01.mkv`,
DurationSec: 100,
}
if err := svc.repo.DB.Create(&media).Error; err != nil {
t.Fatalf("create media: %v", err)
}
key := svc.embyLatestCacheKey("user-1", "", 16)
svc.cache.SetJSONWithStale(t.Context(), key, embyLatestCacheValue{
Items: []map[string]any{{"Id": "cached-item"}},
}, time.Minute, time.Hour)
resetEmbyInvalThrottle(t)
// 60s / 100s:播放中途
if err := svc.RecordProgressWithSession(t.Context(), "user-1", mediaID, 600_000_000, 1_000_000_000, "session-1"); err != nil {
t.Fatalf("record progress: %v", err)
}
if _, stale, ok := svc.cachedLatestItemsWithStale(t.Context(), key); !ok || stale {
t.Fatal("普通进度上报不应让 Emby 列表载荷缓存失效")
}
resetEmbyInvalThrottle(t)
// 98s / 100s:进入片尾 30s 内的「已看完」区间
if err := svc.RecordProgressWithSession(t.Context(), "user-1", mediaID, 980_000_000, 1_000_000_000, "session-2"); err != nil {
t.Fatalf("record completed progress: %v", err)
}
if _, _, ok := svc.cachedLatestItemsWithStale(t.Context(), key); ok {
t.Fatal("看完状态翻转后应让 Emby 列表载荷缓存失效")
}
}
// TestFindSeriesGroupCachesSiblingVirtualSeries 覆盖 N+1 回归点:一次全表
// 扫描算出了整库的剧集分组,若只缓存命中的那一部,同一页的 16 张卡片和
// 16 张海报会各自重扫一遍 media 表。批量记忆后同批分组都应可命中。
func TestFindSeriesGroupCachesSiblingVirtualSeries(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)
}
shows := []string{"Alpha", "Bravo", "Charlie"}
rows := make([]model.Media, 0, len(shows)*2)
for _, show := range shows {
for ep := 1; ep <= 2; ep++ {
rows = append(rows, model.Media{
Base: model.Base{ID: show + "-" + strconv.Itoa(ep)},
LibraryID: lib.ID,
Title: show,
Path: fmt.Sprintf("/media/tv/%s/Season 01/S01E%02d.mkv", show, ep),
SeasonNum: 1,
EpisodeNum: ep,
})
}
}
for i := range rows {
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
seriesIDs := map[string]string{}
for i := range rows {
if rows[i].EpisodeNum == 1 {
seriesIDs[rows[i].Title] = svc.seriesIDForMedia(t.Context(), &rows[i])
}
}
target, ok := seriesIDs["Alpha"]
if !ok {
t.Fatal("missing fixture series")
}
if _, found, err := svc.findSeriesGroup(t.Context(), target, ""); err != nil || !found {
t.Fatalf("findSeriesGroup(%s) = (%v, %v), want found", target, found, err)
}
for show, id := range seriesIDs {
if show == "Alpha" {
continue
}
if _, cached := svc.cachedSeriesGroup(id); !cached {
t.Fatalf("同一次扫描算出的 %s 应一并进入缓存,否则每张卡片/海报都会重扫整库", show)
}
}
}
// TestLatestPayloadKeepsStaleCopyForIdleClients 保证「最新添加」载荷在新鲜期
// 之后仍有可用的陈旧副本:客户端空闲很久后再打开首页时,先拿陈旧结果并由
// 后台重建,而不是让全部媒体库同时回源。
func TestLatestPayloadKeepsStaleCopyForIdleClients(t *testing.T) {
svc := newCachedTestEmbyService(t)
key := svc.embyLatestCacheKey("user-1", "", 16)
svc.cache.SetJSONWithStale(t.Context(), key, embyLatestCacheValue{
Items: []map[string]any{{"Id": "stale-item"}},
}, time.Nanosecond, embyLatestStaleWindow)
time.Sleep(2 * time.Millisecond)
items, stale, ok := svc.cachedLatestItemsWithStale(t.Context(), key)
if !ok || !stale {
t.Fatalf("expected a stale hit, got ok=%v stale=%v", ok, stale)
}
if len(items) != 1 || items[0]["Id"] != "stale-item" {
t.Fatalf("unexpected stale payload: %#v", items)
}
}
// mediaScanCounter 统计真正落到 media 表上的查询次数,用来断言「整库数据
// 只扫一次」:GORM 的 Trace 回调里能拿到最终 SQL。
type mediaScanCounter struct {
logger.Interface
mu sync.Mutex
scans int
}
func (c *mediaScanCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
sql, _ := fc()
if strings.Contains(sql, "FROM `media`") {
c.mu.Lock()
c.scans++
c.mu.Unlock()
}
}
func (c *mediaScanCounter) Scans() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.scans
}
// TestVirtualSeriesLookupsShareOneScan 覆盖冷启动风暴的回归点:客户端刷新
// 首页时 73 个库的卡片和海报各自要一个虚拟 series 分组。修好之后,第一次
// 扫描把整库分组都写进缓存,同一页剩下的查询都不该再落 SQL;随后一个不存在
// 的虚拟 ID 也不该反复触发整库重扫。
func TestVirtualSeriesLookupsShareOneScan(t *testing.T) {
counter := &mediaScanCounter{Interface: logger.Discard}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: counter})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
svc := NewEmbyService(&config.Config{}, zap.NewNop(), repository.New(db))
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)
}
shows := []string{"Alpha", "Bravo", "Charlie"}
rows := make([]model.Media, 0, len(shows)*2)
for _, show := range shows {
for ep := 1; ep <= 2; ep++ {
rows = append(rows, model.Media{
Base: model.Base{ID: show + "-" + strconv.Itoa(ep)},
LibraryID: lib.ID,
Title: show,
Path: fmt.Sprintf("/media/tv/%s/Season 01/S01E%02d.mkv", show, ep),
SeasonNum: 1,
EpisodeNum: ep,
})
}
}
for i := range rows {
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
seriesIDs := map[string]string{}
for i := range rows {
if rows[i].EpisodeNum == 1 {
seriesIDs[rows[i].Title] = svc.seriesIDForMedia(t.Context(), &rows[i])
}
}
if _, found, err := svc.findSeriesGroup(t.Context(), seriesIDs["Alpha"], ""); err != nil || !found {
t.Fatalf("findSeriesGroup(Alpha) = (%v, %v), want found", found, err)
}
if got := counter.Scans(); got != 1 {
t.Fatalf("首次查询应恰好扫描 media 一次,实际 %d 次", got)
}
for _, show := range []string{"Bravo", "Charlie"} {
if _, found, err := svc.findSeriesGroup(t.Context(), seriesIDs[show], ""); err != nil || !found {
t.Fatalf("findSeriesGroup(%s) = (%v, %v), want found", show, found, err)
}
}
if got := counter.Scans(); got != 1 {
t.Fatalf("同批分组应命中缓存,不应再扫 media,实际累计 %d 次", got)
}
if _, found, err := svc.findSeriesGroup(t.Context(), embyVirtualSeriesPrefix+"missing", ""); err != nil || found {
t.Fatalf("未知虚拟 ID 应为 not found,实际 found=%v err=%v", found, err)
}
if got := counter.Scans(); got != 1 {
t.Fatalf("刚扫过整库后,未知 ID 不应重扫 media,实际累计 %d 次", got)
}
}
+37 -1
View File
@@ -56,6 +56,17 @@ type EmbyService struct {
virtualSeries map[string]embySeriesCacheEntry
virtualSeasons map[string]embySeasonCacheEntry
virtualArtwork map[string]embyArtworkCacheEntry
// virtualGroups 把「整库虚拟剧集分组」的扫描折叠成一次。
//
// 客户端刷新首页时会同时请求 73 个库的 Latest,每个库的卡片和海报都要
// 各自的虚拟 series 分组。不同剧的分组缓存键不同,singleflight 键也不同,
// 于是第一波请求会并发重扫整张 media 表(生产环境实测冷启动 14-17s)。
// 按用户折叠成一次扫描后,同一波请求共享结果并整批进缓存。
virtualGroups singleflight.Group
// virtualScanAt 记录每个用户最近一次整库扫描的时间,用于避免为同一个
// 不存在的 id 反复重扫(必须按用户区分:可见性不同,别人的快照不能当
// 自己的答案)。由 virtualMu 保护。
virtualScanAt map[string]time.Time
visibilityMu sync.RWMutex
visibilityCache map[string]embyVisibilityCacheEntry
@@ -186,6 +197,15 @@ const (
embyVirtualSeriesCap = 8000
embyVirtualSeasonCap = 16000
embyVirtualArtworkCap = 24000
// embySeriesGroupBulkCacheEpisodes 限制一次批量记忆的剧集行数。
//
// 批量记忆把「一次扫描服务整页卡片 + 整页海报」的收益拿满,但同时让整库
// 剧集常驻内存。超过该量级时退回只缓存命中的那一部,避免大库吃光内存。
embySeriesGroupBulkCacheEpisodes = 8000
// embyVirtualScanGrace 是「刚做过整库虚拟剧集扫描」的保护窗口。窗口内再
// 请求一个不在缓存里的虚拟 series ID,直接按不存在处理,避免为同一个
// 未知 id 反复重扫整库。
embyVirtualScanGrace = 10 * time.Second
// Clients that already cached the 1x1 placeholder treat a stable tag as
// immutable. Virtual ids are the ones that served that placeholder, so
// only those tags get a suffix that forces a refetch.
@@ -325,8 +345,24 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
}
}
// 客户端刷新首页时常用 Movie,Series,Video 这种混合类型一次取「最新」行。
// 只有剧集库才该折叠成剧集卡片:seriesItemsForLibrary 只查
// (season_num>0 OR episode_num>0),电影库走到这里会让整行返回 0 条。
if containsItemType(p.IncludeItemTypes, "Series") && !containsItemType(p.IncludeItemTypes, "Episode") {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
episodic := true
if p.ParentID != "" {
if value, err := e.libraryIsEpisodic(ctx, p.ParentID); err == nil {
episodic = value
}
}
if episodic {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
}
// 非剧集库:只请求 Series 时确实没有可返回的条目;混合类型则落回
// mediaItems,由它按 Movie 过滤,而不是整行判空。
if !containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Video") {
return emptyItemsEnvelope(p.StartIndex), nil
}
}
// 电影库的「常规浏览」(未指定 IncludeItemTypes): 电影库里偶尔混入按
+10 -1
View File
@@ -6,6 +6,7 @@ import (
"sort"
"strconv"
"strings"
"time"
)
type embyItemsCacheValue struct {
@@ -77,7 +78,15 @@ func (e *EmbyService) embySimilarCacheKey(mediaID, userID string, limit int) str
}
// defaultEmbyLatestCacheTTLSeconds 是 Emby「最新添加」缓存的兜底时长。
const defaultEmbyLatestCacheTTLSeconds = 300
const defaultEmbyLatestCacheTTLSeconds = 900
// embyLatestStaleWindow 是「最新添加」载荷在进程内保留的陈旧副本时长。
//
// 客户端空闲很久之后再打开首页时,新鲜期早已过去:生产环境观察到这时
// 73 个媒体库的 Latest 同时回源,首批请求要 8-25s 才回来,客户端等不及
// 还会重试,进一步放大压力。保留陈旧副本后,这类请求立刻拿到上次的结果,
// 由后台异步重建(见 refreshLatestItemsAsync),用户不再等 SQL。
const embyLatestStaleWindow = 24 * time.Hour
// embyLatestCacheTTLSeconds 返回「最新添加」列表的缓存时长。它刻意比通用
// 媒体缓存更长:客户端刷新首页时会同时请求全部媒体库的 Latest(生产环境
+1 -1
View File
@@ -153,7 +153,7 @@ func (e *EmbyService) loadLatestItemsCached(ctx context.Context, userID, parentI
}
if e.cache != nil {
freshTTL := time.Duration(e.embyLatestCacheTTLSeconds()) * time.Second
e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+30*time.Minute)
e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+embyLatestStaleWindow)
}
e.rememberArtworkRefs(value.Artwork)
return value, nil
+130 -40
View File
@@ -41,47 +41,65 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
return embySeriesGroup{}, false, nil
}
if strings.HasPrefix(id, embyVirtualSeriesPrefix) {
if group, ok := e.cachedSeriesGroup(id); ok {
return group, true, nil
}
return e.findVirtualSeriesGroup(ctx, id, userID)
}
var rows []model.Media
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0")
q = e.applyUserMediaVisibility(ctx, q, userID)
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
q = q.Where("series_id = ?", id)
} else {
// 虚拟 series ID 只可能来自 series_id 为空的媒体:
// 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。
q = q.Where("series_id IS NULL OR series_id = ''")
}
q = q.Where("series_id = ?", id)
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) {
groups := e.seriesGroupsFromMedia(ctx, rows)
bulkCached := e.rememberScannedSeriesGroups(groups)
for _, group := range groups {
if group.ID == id {
e.rememberSeriesGroup(group)
if !bulkCached {
e.rememberSeriesGroup(group)
}
return group, true, nil
}
}
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
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
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{}, false, nil
}
// findVirtualSeriesGroup 处理 series_id 为空的剧集分组(虚拟 series ID)。
//
// 虚拟 ID 是 hash(库名 + 剧名),无法反解,只能整库算出分组后按 ID 取用。
// 这里走 loadVirtualSeriesGroups,把同一用户的并发请求折叠成一次扫描:
// 首页一次刷新会同时要 73 个库的卡片和海报,各扫一遍在 2 核主机上要 14-17s。
func (e *EmbyService) findVirtualSeriesGroup(ctx context.Context, id, userID string) (embySeriesGroup, bool, error) {
if group, ok := e.cachedSeriesGroup(id); ok {
return group, true, nil
}
if e.virtualScanRecently(userID) {
return embySeriesGroup{}, false, nil
}
groups, err := e.loadVirtualSeriesGroups(ctx, userID)
if err != nil {
return embySeriesGroup{}, false, err
}
for _, group := range groups {
if group.ID == id {
return group, true, nil
}
}
return embySeriesGroup{}, false, nil
@@ -103,20 +121,14 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
return season, true, nil
}
// 回退:未刮削(series_id 为空,虚拟 key 由库名+名称派生)的媒体只能全量分组。
var rows []model.Media
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
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.episode_fraction asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
// 复用共享扫描:首页并发刷新时整库只扫一次。
groups, err := e.loadVirtualSeriesGroups(ctx, userID)
if err != nil {
return embySeasonGroup{}, false, err
}
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
for _, series := range groups {
for _, season := range e.seasonsForSeries(series) {
if season.ID == id {
e.rememberSeriesGroup(series)
return season, true, nil
}
}
@@ -186,6 +198,84 @@ func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, us
return embySeasonGroup{}, false, nil
}
// rememberScannedSeriesGroups 批量记住一次扫描得到的全部分组,返回是否真的
// 批量写入(超过剧集预算时不做,调用方需自行缓存命中的那一部)。
//
// 只缓存命中的一部时,同一部剧的多张卡片/海报仍要各扫一次;整批写入后,
// 同一次扫描算出来的其他剧也直接命中,于是整页 16 张卡片 + 16 张海报共用
// 一次全表扫描。
func (e *EmbyService) rememberScannedSeriesGroups(groups []embySeriesGroup) bool {
if e == nil || len(groups) == 0 {
return false
}
episodes := 0
for i := range groups {
episodes += len(groups[i].Episodes)
if episodes > embySeriesGroupBulkCacheEpisodes {
return false
}
}
e.rememberSeriesGroups(groups)
return true
}
// loadVirtualSeriesGroups 返回 series_id 为空的整库剧集分组。
//
// 同一用户的并发调用共享同一次扫描:客户端刷新首页时 73 个库的 Latest 同时
// 进来,每个库的卡片和海报又各自要自己的虚拟 series 分组。分组缓存键各不
// 相同,singleflight 也就不会合并,于是第一波请求把整张 media 表并发重算
// 了 73 次(生产环境实测 14-17s)。这里按用户折叠成一次,结果整批进缓存,
// 调用方拿到切片后按 ID 取用即可。
func (e *EmbyService) loadVirtualSeriesGroups(ctx context.Context, userID string) ([]embySeriesGroup, error) {
if e == nil || e.repo == nil {
return nil, nil
}
v, err, _ := e.virtualGroups.Do("virtual:"+userID, func() (any, error) {
// 与 loadLatestItemsCached 一致:一个客户端断开不应取消正在为其他
// 客户端填充的共享扫描。
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
var rows []model.Media
q := e.repo.DB.WithContext(loadCtx).Model(&model.Media{}).
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
q = e.applyUserMediaVisibility(loadCtx, q, userID)
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 nil, err
}
groups := e.seriesGroupsFromMedia(loadCtx, rows)
e.rememberSeriesGroups(groups)
e.markVirtualScan(userID)
return groups, nil
})
if err != nil {
return nil, err
}
groups, _ := v.([]embySeriesGroup)
return groups, nil
}
// markVirtualScan 记录该用户最近一次整库扫描的时间。
func (e *EmbyService) markVirtualScan(userID string) {
e.virtualMu.Lock()
if e.virtualScanAt == nil {
e.virtualScanAt = make(map[string]time.Time, 4)
}
e.virtualScanAt[userID] = time.Now()
e.virtualMu.Unlock()
}
// virtualScanRecently 报告该用户是否刚做过一次整库扫描。窗口内仍不在缓存里
// 的目标 ID 按不存在处理,避免为同一个未知 ID 反复重扫整库。
func (e *EmbyService) virtualScanRecently(userID string) bool {
e.virtualMu.RLock()
scannedAt := e.virtualScanAt[userID]
e.virtualMu.RUnlock()
return !scannedAt.IsZero() && time.Since(scannedAt) < embyVirtualScanGrace
}
func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Media) []embySeriesGroup {
byID := map[string]*embySeriesGroup{}
order := []string{}
+29 -3
View File
@@ -26,19 +26,45 @@ func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
if e == nil || strings.TrimSpace(group.ID) == "" {
return
}
e.rememberSeriesGroups([]embySeriesGroup{group})
}
// rememberSeriesGroups 批量记住一组剧集分组(含派生季与封面)。
//
// 一次全表扫描会同时算出整库的剧集分组,而调用方往往只关心其中一部。若只
// 逐个 rememberSeriesGroup,同一次首页刷新里 16 张卡片 + 16 张海报都要各自
// 命中自己的虚拟 series ID,未命中就重扫整张 media 表(生产环境实测单次
// 1.3s,73 个库并发时把连接池排满,首页要 8-25s)。批量写入后冷缓存只付
// 一次扫描,其余全部是 O(1) 命中。
func (e *EmbyService) rememberSeriesGroups(groups []embySeriesGroup) {
if e == nil || len(groups) == 0 {
return
}
expiresAt := time.Now().Add(embyVirtualCacheTTL)
e.virtualMu.Lock()
defer e.virtualMu.Unlock()
if e.virtualSeries == nil {
e.virtualSeries = make(map[string]embySeriesCacheEntry)
e.virtualSeries = make(map[string]embySeriesCacheEntry, len(groups))
}
if e.virtualSeasons == nil {
e.virtualSeasons = make(map[string]embySeasonCacheEntry)
e.virtualSeasons = make(map[string]embySeasonCacheEntry, len(groups))
}
if e.virtualArtwork == nil {
e.virtualArtwork = make(map[string]embyArtworkCacheEntry)
e.virtualArtwork = make(map[string]embyArtworkCacheEntry, len(groups)*2)
}
for _, group := range groups {
e.rememberSeriesGroupLocked(group, expiresAt)
}
// 写入后再裁剪:逐条裁剪会让整批写入退化成 O(n²),且中途裁剪可能把
// 同一批里刚写进去的分组又淘汰掉。
e.trimVirtualCachesLocked(time.Now())
}
// rememberSeriesGroupLocked 写入单个分组,调用方必须已持有 virtualMu。
func (e *EmbyService) rememberSeriesGroupLocked(group embySeriesGroup, expiresAt time.Time) {
if strings.TrimSpace(group.ID) == "" {
return
}
e.virtualSeries[group.ID] = embySeriesCacheEntry{group: group, expiresAt: expiresAt}
e.virtualArtwork[group.ID] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
e.virtualArtwork[group.ID+"-bd"] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt}
+27 -2
View File
@@ -2,6 +2,9 @@ package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"sort"
"strings"
"time"
@@ -278,6 +281,20 @@ func isSupportedEmbyCollectionType(t string) bool {
return false
}
// libraryCoverTag 生成媒体库拼图封面的图片版本号。
//
// 第三方客户端把库主图按 (URL, ImageTag) 缓存,tag 不变就不会重新请求。
// 用成员海报列表的指纹做 tag,库内容变化或生成规则调整时客户端自然会刷新。
func libraryCoverTag(libraryID string, sources []string) string {
h := sha256.New()
_, _ = io.WriteString(h, libraryID)
for _, src := range sources {
_, _ = io.WriteString(h, src)
_, _ = h.Write([]byte{0})
}
return libraryID + "-" + hex.EncodeToString(h.Sum(nil))[:8]
}
func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[string]any {
collectionType := "movies"
switch l.Type {
@@ -292,9 +309,17 @@ func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[s
}
// 库封面:与剧集/电影一样,只有在能解析出封面时才广告 ImageTags.Primary,
// 否则客户端会认为该库无主图而不去请求 /Images/Primary。
//
// 拼图封面的 tag 带上「成员海报」的指纹:库内容变化或生成规则调整(例如
// 从 2×2 改成竖切)时 tag 会变,客户端才会丢缓存重新拉图;固定用库 ID
// 会让第三方客户端永远显示第一次缓存下来的封面。
imageTags := map[string]string{}
if e.LibraryHasCover(ctx, l.ID) {
imageTags["Primary"] = l.ID
if sources, lib := LibraryCoverSourcesForLibrary(ctx, e.repo, l.ID); lib != nil {
if len(sources) > 0 {
imageTags["Primary"] = libraryCoverTag(l.ID, sources)
} else if strings.TrimSpace(lib.CoverURL) != "" {
imageTags["Primary"] = l.ID
}
}
return map[string]any{
"Id": l.ID,
+11 -5
View File
@@ -136,7 +136,12 @@ func (e *EmbyService) RecordProgressWithSession(ctx context.Context, userID, med
SessionID: playSessionID,
SessionStartedAtMs: embyPlaySessionStartedAtMs(playSessionID),
})
if err == nil {
// 普通进度上报不清列表载荷缓存。播放期间客户端每 5-10 秒上报一次,
// 每次清空 media:emby:* 会让「最新添加」整批 payload(客户端刷新首页时
// 一次并发 73 个库)反复回源 SQL,生产环境实测因此出现 8-25s 的加载。
// 续播位置由 /Items/Resume 与条目详情实时查库,列表里的进度条允许滞后;
// 只有「已看完」这类状态翻转才让缓存失效(position 到达片尾 30s 内)。
if err == nil && playbackProgressCompleted(pos, dur) {
e.invalidateEmbyItemsCache(ctx)
}
return err
@@ -287,10 +292,11 @@ func applyMeBoxUserData(raw any, history *model.PlaybackHistory, favorite bool)
return userData
}
// embyInvalMu 节流全量缓存失效:播放期间客户端每 5-10s 上报一次进度,
// 每次都 SCAN+DEL 全部 media:emby:* 缓存会把缓存命中率持续打穿(其他
// 客户端每次翻页都回源 SQL)。条目载荷的 UserData 在请求时动态合并,
// 进度类变更做 30s 节流即可,不影响正确性观感。
// embyInvalMu 节流全量缓存失效:收藏/标记已看这类显式操作会改变列表里的
// UserData,必须让 payload 失效。客户端连续点选时会短时间触发多次,节流到
// 30s 一次即可,避免每次都 SCAN+DEL 全部 media:emby:* 缓存。
// 播放进度上报不走这里(见 RecordProgressWithSession):续播位置实时查库,
// 列表里的进度条允许滞后,不值得为它把整批首页载荷打回源 SQL。
var (
embyInvalMu sync.Mutex
embyInvalLast time.Time
@@ -144,3 +144,43 @@ func (c *Container) NormalizePollutedEpisodeMetadata(ctx context.Context) (int,
}
return cleaned, nil
}
// segmentRangeCleanupSettingKey marks the one-time removal of impossible
// skip ranges written by the older end_ms: 0 interpretation.
const segmentRangeCleanupSettingKey = "segment.range_cleanup_v1_done"
// NormalizeImpossibleSegments 删除历史误写进来的「不可能区间」。
//
// 旧解析把 TheIntroDB 的 end_ms: 0 当成「延续到片尾」,于是
// intro:[{start_ms:null,end_ms:0}] 被落成 intro (0, 0),客户端补齐后覆盖整集;
// 同理 recap (0,0) 也会让整集变成回顾。这类行只会让播放器一开播就弹跳过按钮,
// 没有任何保留价值,直接硬删。
//
// 幂等:完成后写 setting 标记。只处理片头/回顾——片尾类允许开放结束点。
func (c *Container) NormalizeImpossibleSegments(ctx context.Context) (int, error) {
if c == nil || c.Repo == nil || c.Repo.DB == nil {
return 0, nil
}
if c.Repo.Setting != nil {
if v, err := c.Repo.Setting.Get(ctx, segmentRangeCleanupSettingKey); err == nil && strings.TrimSpace(v) == "true" {
return 0, nil
}
}
res := c.Repo.DB.WithContext(ctx).Unscoped().
Where("kind IN ?", []string{model.SegmentKindIntro, model.SegmentKindRecap}).
Where("end_ms <= start_ms").
Delete(&model.MediaSegment{})
if res.Error != nil {
return 0, res.Error
}
cleaned := int(res.RowsAffected)
if c.Repo.Setting != nil {
if err := c.Repo.Setting.Set(ctx, segmentRangeCleanupSettingKey, "true"); err != nil && c.Log != nil {
c.Log.Warn("mark segment range cleanup done failed", zap.Error(err))
}
}
if cleaned > 0 && c.Log != nil {
c.Log.Info("impossible skip segments removed", zap.Int("rows", cleaned))
}
return cleaned, nil
}
+7 -9
View File
@@ -199,21 +199,19 @@ func imageResizeConcurrency() int {
return n
}
// Prune removes oldest cached images until disk usage is within the configured limit.
// 原图池按独立配额与保留时长优先淘汰,客户端热路径读取的成品图最后淘汰。
// Prune 只执行图片原图子配额与旧缓存清理;整个磁盘缓存的总量淘汰由调度器的
// cache_cleanup 任务(PruneCache)负责。
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
if p.cfg == nil {
return PruneImageCacheResult{}, nil
}
var originalsMaxBytes int64
if p.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 {
originalsMaxBytes = int64(p.cfg.Cache.ImagesOriginalsMaxSizeMB) * 1024 * 1024
}
var originalsMaxAge time.Duration
if p.cfg.Cache.ImagesOriginalsTTLHours > 0 {
originalsMaxAge = time.Duration(p.cfg.Cache.ImagesOriginalsTTLHours) * time.Hour
if originalsMaxBytes <= 0 {
return PruneImageCacheResult{}, nil
}
totalBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
pools := ImageCachePools(p.cacheDir, originalsMaxBytes, originalsMaxAge)
return PruneImageCachePools(pools, totalBytes)
pools := ImageCachePools(p.cacheDir, originalsMaxBytes)
return PruneImageCachePools(pools, 0)
}
+31 -17
View File
@@ -41,15 +41,6 @@ const (
introDBMaxRetryDelay = 3 * time.Second
)
// IntroDBSpan is one resolved skip range, still in provider terms.
// EndMs == 0 means "runs to the end of the media" (TheIntroDB returns
// end_ms: null for end credits); the caller resolves it against the duration.
type IntroDBSpan struct {
Kind string
StartMs int64
EndMs int64
}
// IntroDBService queries TheIntroDB for one media item.
type IntroDBService struct {
log *zap.Logger
@@ -113,7 +104,7 @@ type introDBResponse struct {
// 429/503 会重试一次(社区库在短时间连发下确实会限流)。重试前会先确认调用方
// 的 deadline 还够用;预算不够就直接返回错误,让调用方保留自己的缓存,
// 把「拿不到片段」维持在「少一个跳过按钮」的量级。
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]IntroDBSpan, error) {
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]SegmentSpan, error) {
if s == nil || s.client == nil {
return nil, errors.New("introdb service nil")
}
@@ -135,9 +126,20 @@ func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int)
}
}
// Source implements SegmentProvider.
func (s *IntroDBService) Source() string { return IntroDBSource }
// FetchSegments implements SegmentProvider. TheIntroDB 以 TMDb id 寻址。
func (s *IntroDBService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
if q.IsMovie {
return s.Fetch(ctx, q.TMDbID, 0, 0)
}
return s.Fetch(ctx, q.TMDbID, q.Season, q.Episode)
}
// introDBFetchAttempt 是一次请求的结果:数据或错误,外加「值不值得重试」。
type introDBFetchAttempt struct {
spans []IntroDBSpan
spans []SegmentSpan
err error
retryable bool
retryAfter time.Duration
@@ -246,7 +248,7 @@ func (s *IntroDBService) mediaURL(tmdbID, season, episode int) string {
// parseIntroDBResponse flattens the per-type arrays into spans, preserving the
// intro -> recap -> credits -> preview order so the player sees the earliest
// range first.
func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
func parseIntroDBResponse(body []byte) ([]SegmentSpan, error) {
var raw introDBResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse introdb json: %w", err)
@@ -260,7 +262,7 @@ func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
{"credits", raw.Credits},
{"preview", raw.Preview},
}
spans := make([]IntroDBSpan, 0, len(raw.Intro)+len(raw.Credits))
spans := make([]SegmentSpan, 0, len(raw.Intro)+len(raw.Credits))
for _, group := range groups {
for _, r := range group.ranges {
var start int64
@@ -274,12 +276,24 @@ func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
if start < 0 {
start = 0
}
// end == 0 表示「延续到片尾」,是合法值;其余情况 end 必须大于 start,
// 否则这段区间没有任何可跳过的内容,直接丢弃避免在播放器里出现空按钮。
if end != 0 && end <= start {
// 官方规范(theintrodb.org/openapi.yaml,SegmentTimestamp)区分了两种
// 「没有数值」的写法,含义完全不同,必须按指针区分而不能都归零:
// end_ms: null —— Credits/Preview 专用,「一直到片尾」,合法,
// 落成 0 作为哨兵值由客户端结合片长补齐;
// end_ms: 0 —— 「no segment」,即这一段不存在。
// 早期实现把两者都当成「到片尾」,于是 intro:[{start_ms:null,end_ms:0}]
// 被解释成 [0, 片长],整集都落在「片头」区间里:一开播就弹跳过按钮,
// 开了自动跳过更是直接跳到片尾。实测约三分之一的剧集会返回这种占位。
if r.EndMs != nil && end <= start {
continue
}
spans = append(spans, IntroDBSpan{Kind: group.kind, StartMs: start, EndMs: end})
// 只有片尾类区间才允许「开放结束点」(end_ms: null 落成 0)。片头/回顾
// 如果出现 null 结束点,补齐后的区间会覆盖到片尾,等于把整集判成片头,
// 所以宁可不显示也不猜。
if r.EndMs == nil && group.kind != "credits" && group.kind != "preview" {
continue
}
spans = append(spans, SegmentSpan{Kind: group.kind, StartMs: start, EndMs: end})
}
}
return spans, nil
+160
View File
@@ -0,0 +1,160 @@
// Package service — IntroDB (https://introdb.app) segment client.
//
// 与 TheIntroDB 是两家不同的社区库:IntroDB 只接受 IMDb id 定位媒体,
// 用 is_movie 区分电影与剧集,响应把每种区间直接给成 {start_ms,end_ms} 对象
// (而不是按类型分数组),片尾字段名是 outro 而不是 credits,
// 另外还有 post_credits(片尾彩蛋,映射为预告/额外片尾)。
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go.uber.org/zap"
)
// IntroDBAppBaseURL 是公开只读端点。POST /submit 需要 API key,这里不做写入。
const IntroDBAppBaseURL = "https://api.introdb.app"
// IntroDBAppSource 写入 media_segments.source,与 TheIntroDB 的 "theintrodb" 区分。
const IntroDBAppSource = "introdb"
const (
introDBAppTimeout = 8 * time.Second
introDBAppMaxBodySize = 1 << 20
)
// introDBAppSegment 是 IntroDB 的单个区间对象。null 表示该类型没有数据。
type introDBAppSegment struct {
StartMs *int64 `json:"start_ms"`
EndMs *int64 `json:"end_ms"`
}
// introDBAppResponse 对应 GET /segments。
type introDBAppResponse struct {
IMDbID string `json:"imdb_id"`
MediaType string `json:"media_type"`
IsMovie bool `json:"is_movie"`
Season int `json:"season"`
Episode int `json:"episode"`
Intro *introDBAppSegment `json:"intro"`
Recap *introDBAppSegment `json:"recap"`
Outro *introDBAppSegment `json:"outro"`
PostCredits *introDBAppSegment `json:"post_credits"`
}
// IntroDBAppService 查询 IntroDB 的片段数据。
type IntroDBAppService struct {
log *zap.Logger
client *http.Client
baseURL string
}
// NewIntroDBAppService is the constructor. The client honours environment and
// OS proxy settings so it behaves like the other third-party API clients.
func NewIntroDBAppService(log *zap.Logger) *IntroDBAppService {
return &IntroDBAppService{
log: log,
client: NewExternalHTTPClient(introDBAppTimeout),
baseURL: IntroDBAppBaseURL,
}
}
// SetBaseURL overrides the API root (tests, mirrors).
func (s *IntroDBAppService) SetBaseURL(base string) *IntroDBAppService {
if s != nil && strings.TrimSpace(base) != "" {
s.baseURL = strings.TrimRight(strings.TrimSpace(base), "/")
}
return s
}
// Source implements SegmentProvider.
func (s *IntroDBAppService) Source() string { return IntroDBAppSource }
// FetchSegments implements SegmentProvider.
//
// 没有 IMDb id 时直接返回空:IntroDB 不接受 TMDb id,用别的 id 拼请求只会拿到
// 400,那会被误记成「查询失败」从而保留旧缓存、永远不再刷新。
func (s *IntroDBAppService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
if s == nil || s.client == nil || strings.TrimSpace(q.IMDbID) == "" {
return nil, nil
}
endpoint := s.segmentsURL(q)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
// 404 = 库里没有这部片,是正常的「无数据」,不是错误。
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("introdb: unexpected status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, introDBAppMaxBodySize))
if err != nil {
return nil, err
}
return parseIntroDBAppResponse(body)
}
func (s *IntroDBAppService) segmentsURL(q SegmentQuery) string {
values := url.Values{}
values.Set("imdb_id", strings.TrimSpace(q.IMDbID))
if q.IsMovie {
values.Set("is_movie", "true")
} else if q.Season > 0 && q.Episode > 0 {
values.Set("season", strconv.Itoa(q.Season))
values.Set("episode", strconv.Itoa(q.Episode))
}
return s.baseURL + "/segments?" + values.Encode()
}
// parseIntroDBAppResponse 把 IntroDB 的四个区间对象摊平成 spans。
//
// 顺序固定为 intro -> recap -> outro -> post_credits,与 TheIntroDB 的
// intro/recap/credits/preview 对齐,后处理逻辑因此可以对来源无感。
func parseIntroDBAppResponse(body []byte) ([]SegmentSpan, error) {
var raw introDBAppResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse introdb json: %w", err)
}
groups := []struct {
kind string
seg *introDBAppSegment
}{
{"intro", raw.Intro},
{"recap", raw.Recap},
{"credits", raw.Outro},
{"preview", raw.PostCredits},
}
spans := make([]SegmentSpan, 0, len(groups))
for _, group := range groups {
if group.seg == nil || group.seg.StartMs == nil || group.seg.EndMs == nil {
continue
}
start, end := *group.seg.StartMs, *group.seg.EndMs
if start < 0 {
start = 0
}
if end <= start {
continue
}
spans = append(spans, SegmentSpan{Kind: group.kind, StartMs: start, EndMs: end})
}
return spans, nil
}
+564
View File
@@ -0,0 +1,564 @@
package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
"image/jpeg"
"io"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"golang.org/x/image/draw"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 媒体库封面拼图(Emby 风格的「封面集」)。
//
// Emby / Jellyfin 在没有管理员自定义封面时,会用库内几张海报拼成一张
// 2×2 的合成图作为库主图(folder image)。MeBox 早先只是挑评分最高的
// 那一张单图,于是每个库的封面看起来都像「随便选了一张」。这里按同样的
// 思路生成拼图,供 Emby 客户端、Web 和 App 共用同一份图片。
const (
// libraryCoverDefaultWidth / Height 是拼图输出的默认画布。
//
// 库条目的 PrimaryImageAspectRatio 报的是 16:9(见 emby_system.go),
// 客户端按这个比例摆卡片;生成同样比例的图可以避免客户端再裁一刀。
libraryCoverDefaultWidth = 480
libraryCoverDefaultHeight = 270
libraryCoverMinWidth = 96
libraryCoverMaxWidth = 1920
libraryCoverAspect = 16.0 / 9.0
// libraryCoverSourceLimit 是拼图实际取用的成员海报数上限。
//
// 封面是竖切分栏:三栏在 16:9 画布上每栏接近 3:5,既保留海报主体,又能
// 让一个库有多部作品可辨识;再多就会把海报裁成很窄的竖条。
libraryCoverSourceLimit = 3
// libraryCoverMaxPanels 是合成函数能接受的最多分栏数(更长的来源列表
// 只取前几张)。留 4 是给「按库类型调栏数」留的余量。
libraryCoverMaxPanels = 4
// libraryCoverCandidatePool 是挑选海报时的候选池大小。只看最近的一批
// 成员,避免为一个库封面扫全库;对「最新入库」的库来说这批就是它最好
// 的那几张。
libraryCoverCandidatePool = 200
libraryCoverQuality = 85
libraryCoverSubdir = "library-covers"
)
// libraryCoverSourceCache 缓存「库 → 成员海报来源」。Emby 客户端进入首页
// 时会对每个库各请求一次主图,把这个查询压到每库 10 分钟一次,避免几十个
// 200 行的列表查询同时打进来。
var libraryCoverSourceCache = struct {
mu sync.Mutex
items map[string]libraryCoverSourceEntry
}{items: make(map[string]libraryCoverSourceEntry)}
type libraryCoverSourceEntry struct {
sources []string
expiresAt time.Time
}
const libraryCoverSourceTTL = 10 * time.Minute
func cachedLibraryCoverSources(libraryID string) ([]string, bool) {
libraryCoverSourceCache.mu.Lock()
defer libraryCoverSourceCache.mu.Unlock()
entry, ok := libraryCoverSourceCache.items[libraryID]
if !ok || time.Now().After(entry.expiresAt) {
if ok {
delete(libraryCoverSourceCache.items, libraryID)
}
return nil, false
}
return entry.sources, true
}
func rememberLibraryCoverSources(libraryID string, sources []string) {
libraryCoverSourceCache.mu.Lock()
defer libraryCoverSourceCache.mu.Unlock()
if len(libraryCoverSourceCache.items) > 4000 {
libraryCoverSourceCache.items = make(map[string]libraryCoverSourceEntry)
}
libraryCoverSourceCache.items[libraryID] = libraryCoverSourceEntry{
sources: sources,
expiresAt: time.Now().Add(libraryCoverSourceTTL),
}
}
// LibraryCoverSources 返回媒体库封面拼图要用的海报来源,最多 limit 张。
//
// 选法与网页旧逻辑一致(库内最近一批成员,按 seriesArtworkScore 取质量
// 最高者),额外按剧集去重,避免四张海报全是同一部剧的不同集。
func LibraryCoverSources(ctx context.Context, repos *repository.Container, libraryID string, limit int) []string {
if repos == nil || repos.Media == nil || strings.TrimSpace(libraryID) == "" {
return nil
}
if limit <= 0 || limit > libraryCoverSourceLimit {
limit = libraryCoverSourceLimit
}
if cached, ok := cachedLibraryCoverSources(libraryID); ok {
if len(cached) > limit {
return cached[:limit]
}
return cached
}
rows, err := repos.Media.ListByLibrariesFilteredNoCount(ctx, []string{libraryID}, 0, libraryCoverCandidatePool,
repository.MediaQueryFilter{IncludeNSFW: true})
if err != nil || len(rows) == 0 {
return nil
}
type candidate struct {
url string
key string
score int
}
candidates := make([]candidate, 0, len(rows))
for i := range rows {
row := rows[i]
raw := strings.TrimSpace(row.PosterURL)
if raw == "" {
raw = strings.TrimSpace(row.BackdropURL)
}
if raw == "" {
continue
}
key := mediaSeriesKey(row)
if key == "" {
key = "id:" + row.ID
}
candidates = append(candidates, candidate{url: raw, key: key, score: seriesArtworkScore(row)})
}
// 评分高的优先;同分保持数据库顺序(最近的在前),封面更贴近「最新入库」。
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
out := make([]string, 0, limit)
seenKey := make(map[string]struct{}, limit)
seenURL := make(map[string]struct{}, limit)
for _, item := range candidates {
if len(out) >= limit {
break
}
if _, ok := seenKey[item.key]; ok {
continue
}
if _, ok := seenURL[item.url]; ok {
continue
}
seenKey[item.key] = struct{}{}
seenURL[item.url] = struct{}{}
out = append(out, item.url)
}
if len(out) == 0 {
rememberLibraryCoverSources(libraryID, nil)
return nil
}
rememberLibraryCoverSources(libraryID, out)
return out
}
// LibraryCoverCacheDir 返回拼图缓存目录(与图片代理的 images/ 分开存放,
// 不会被图片缓存的配额清理误删)。
func LibraryCoverCacheDir(cfgCacheDir string) string {
base := strings.TrimSpace(cfgCacheDir)
if base == "" {
base = "./cache"
}
return filepath.Join(base, libraryCoverSubdir)
}
// LibraryCoverImage 生成(或命中缓存)媒体库封面拼图。
//
// width/height 是期望的输出画布;服务端会按 16:9 收敛,返回的图片不会
// 超过该边界。sources 由调用方提供(见 LibraryCoverSources),这样 Emby
// 路由和 Web API 可以共用同一套生成与缓存逻辑。
func (p *ImageProxy) LibraryCoverImage(ctx context.Context, libraryID string, sources []string, width, height int) ([]byte, error) {
if p == nil {
return nil, errors.New("image proxy unavailable")
}
libraryID = strings.TrimSpace(libraryID)
sources = normalizeCoverSources(sources)
if libraryID == "" || len(sources) == 0 {
return nil, errors.New("no library cover sources")
}
outW, outH := libraryCoverCanvas(width, height)
cachePath, sourceKey := p.libraryCoverCachePath(libraryID, sources, outW, outH)
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is derived from a content hash.
return data, nil
}
key := fmt.Sprintf("library-cover:%s-%dx%d-%s", libraryID, outW, outH, sourceKey)
v, err, _ := p.fetchGroup.Do(key, func() (any, error) {
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is derived from a content hash.
return data, nil
}
data, buildErr := p.buildLibraryCover(ctx, libraryID, sources, outW, outH, cachePath, sourceKey)
if buildErr != nil {
return nil, buildErr
}
return data, nil
})
if err != nil {
return nil, err
}
data, _ := v.([]byte)
if len(data) == 0 {
return nil, errors.New("empty library cover")
}
return data, nil
}
func (p *ImageProxy) buildLibraryCover(ctx context.Context, libraryID string, sources []string, width, height int, cachePath, sourceKey string) ([]byte, error) {
images := p.fetchCoverSources(ctx, sources)
if len(images) == 0 {
return nil, errors.New("no library cover image fetched")
}
// 解码与合成是 CPU 密集操作:和缩略图共用同一组解码槽,避免一次首页
// 刷新把 2 核主机打满。
release, ok := p.acquireResizeSlotWithin(ctx, p.resizeQueueTimeout())
if ok {
defer release()
}
data, err := composeLibraryCover(images, width, height)
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
return data, nil
}
if err := writeFileAtomic(cachePath, data); err != nil {
p.warn("write library cover cache failed", err)
return data, nil
}
p.pruneLibraryCoverVariants(filepath.Dir(cachePath), libraryID, sourceKey)
return data, nil
}
// fetchCoverSources 并发拉取成员海报(受图片代理自身的缓存/鉴权/SSRF 规则
// 保护)。单张失败只丢弃该张,其余仍然可以拼图。
func (p *ImageProxy) fetchCoverSources(ctx context.Context, sources []string) [][]byte {
out := make([][]byte, len(sources))
var wg sync.WaitGroup
sem := make(chan struct{}, libraryCoverMaxPanels)
fetchCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
for i, raw := range sources {
i, raw := i, raw
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
data, err := p.coverSourceBytes(fetchCtx, raw)
if err != nil || len(data) == 0 {
return
}
out[i] = data
}()
}
wg.Wait()
compacted := make([][]byte, 0, len(out))
for _, data := range out {
if len(data) > 0 {
compacted = append(compacted, data)
}
}
return compacted
}
// coverSourceBytes 读取一张海报来源。
//
// 刮削会把 sidecar 海报存成 /media/... 这类绝对路径,而 fetch 路径只接受
// http(s)(validateURL 要求有 host),直接丢给 Fetch 会全部失败。本地路径
// 按图片代理同一套 isAllowedLocalPath 白名单读盘,远程地址才走 Fetch,
// 这样鉴权、SSRF 校验与磁盘缓存的行为与 /api/img 完全一致。
func (p *ImageProxy) coverSourceBytes(ctx context.Context, raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, errors.New("empty cover source")
}
if isLocalImagePath(raw) {
abs, err := filepath.Abs(filepath.Clean(raw))
if err != nil {
return nil, err
}
if !p.isAllowedLocalPath(abs) {
return nil, errors.New("local cover path not allowed")
}
return os.ReadFile(abs) // #nosec G304 -- path validated against the library roots above.
}
data, _, err := p.Fetch(ctx, raw)
if err != nil {
return nil, err
}
return data, nil
}
// libraryCoverCachePath 生成缓存路径。
//
// 文件名里的 sourceKey 只由成员海报列表决定(不含尺寸):成员变化时换一批
// 文件,同一批成员的多个尺寸变体可以共存,不会被互相删掉。size 单独进文件名,
// 让 Emby(按客户端 maxWidth/maxHeight)与 Web/App(固定档位)各用自己的成品。
func (p *ImageProxy) libraryCoverCachePath(libraryID string, sources []string, width, height int) (string, string) {
h := sha256.New()
for _, src := range sources {
_, _ = io.WriteString(h, src)
_, _ = h.Write([]byte{0})
}
sourceKey := hex.EncodeToString(h.Sum(nil))[:16]
name := fmt.Sprintf("%s-%dx%d-%s.jpg", safeCoverFileID(libraryID), width, height, sourceKey)
return filepath.Join(LibraryCoverCacheDir(p.cacheDirBase()), name), sourceKey
}
func (p *ImageProxy) cacheDirBase() string {
if p != nil && p.cfg != nil {
return p.cfg.Cache.CacheDir
}
return ""
}
// pruneLibraryCoverVariants 清掉同一个库、旧成员列表留下的封面文件(含各
// 尺寸变体),当前成员列表对应的成品全部保留。
func (p *ImageProxy) pruneLibraryCoverVariants(dir, libraryID, keepSourceKey string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
prefix := safeCoverFileID(libraryID) + "-"
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".jpg") {
continue
}
if strings.Contains(name, "-"+keepSourceKey+".jpg") {
continue
}
_ = os.Remove(filepath.Join(dir, name)) // #nosec G304 -- name comes from ReadDir of our own cache dir.
}
}
// safeCoverFileID 让文件名只包含安全字符(库 ID 来自数据库,但仍做一次
// 白名单过滤,避免任何路径分隔符进入文件名)。
func safeCoverFileID(libraryID string) string {
var b strings.Builder
for _, r := range libraryID {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
}
}
if b.Len() == 0 {
return "library"
}
return b.String()
}
func normalizeCoverSources(sources []string) []string {
seen := make(map[string]struct{}, len(sources))
out := make([]string, 0, len(sources))
for _, raw := range sources {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
if _, ok := seen[raw]; ok {
continue
}
seen[raw] = struct{}{}
out = append(out, raw)
if len(out) >= libraryCoverMaxPanels {
break
}
}
return out
}
// libraryCoverCanvas 把请求的尺寸收敛到 16:9 画布,并夹在合理区间内。
func libraryCoverCanvas(width, height int) (int, int) {
if width <= 0 && height <= 0 {
return libraryCoverDefaultWidth, libraryCoverDefaultHeight
}
if width <= 0 {
width = int(float64(height)*libraryCoverAspect + 0.5)
}
if height <= 0 {
height = int(float64(width)/libraryCoverAspect + 0.5)
}
// 客户端给的是一个「不超过」的盒子:按 16:9 装进去。
if float64(width)/float64(height) > libraryCoverAspect {
width = int(float64(height)*libraryCoverAspect + 0.5)
} else {
height = int(float64(width)/libraryCoverAspect + 0.5)
}
if width < libraryCoverMinWidth {
width = libraryCoverMinWidth
height = int(float64(width)/libraryCoverAspect + 0.5)
}
if width > libraryCoverMaxWidth {
width = libraryCoverMaxWidth
height = int(float64(width)/libraryCoverAspect + 0.5)
}
return width, height
}
// composeLibraryCover 把若干张海报拼成库封面:竖切成若干等宽竖条。
//
// - 1 张:铺满整块画布;
// - 2 张:左右各一半(默认形态,对应网页/客户端卡片的分屏观感);
// - 3/4 张:三等分 / 四等分竖条。
//
// 每格按中心裁切铺满(crop-to-fill),这样不同比例的海报都能对齐分栏。
func composeLibraryCover(images [][]byte, width, height int) ([]byte, error) {
if len(images) == 0 {
return nil, errors.New("no images to compose")
}
if width <= 0 || height <= 0 {
return nil, errors.New("invalid cover size")
}
decoded := make([]image.Image, 0, len(images))
for _, data := range images {
img, ok := decodeCoverImage(data)
if ok {
decoded = append(decoded, img)
}
if len(decoded) >= libraryCoverMaxPanels {
break
}
}
if len(decoded) == 0 {
return nil, errors.New("no decodable cover images")
}
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
cells := make([]image.Rectangle, 0, len(decoded))
if len(decoded) == 1 {
cells = append(cells, canvas.Bounds())
} else {
for i := range decoded {
x0 := width * i / len(decoded)
x1 := width * (i + 1) / len(decoded)
cells = append(cells, image.Rect(x0, 0, x1, height))
}
}
for i, cell := range cells {
drawCoverCell(canvas, cell, decoded[i])
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, canvas, &jpeg.Options{Quality: libraryCoverQuality}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// decodeCoverImage 解码一张海报,并对异常大的图做像素上限保护:一次拼图
// 会同时解码 4 张,2 核 2G 的机器上不能任由超大原图把内存吃光。
func decodeCoverImage(data []byte) (image.Image, bool) {
if len(data) == 0 {
return nil, false
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
return nil, false
}
if cfg.Width*cfg.Height > imageResizeMaxSourcePixels {
return nil, false
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, false
}
return img, true
}
// drawCoverCell 把一张图按中心裁切铺满目标格子。
func drawCoverCell(dst *image.RGBA, cell image.Rectangle, src image.Image) {
if src == nil || cell.Dx() <= 0 || cell.Dy() <= 0 {
return
}
bounds := src.Bounds()
srcW, srcH := bounds.Dx(), bounds.Dy()
if srcW <= 0 || srcH <= 0 {
return
}
targetAspect := float64(cell.Dx()) / float64(cell.Dy())
srcAspect := float64(srcW) / float64(srcH)
crop := bounds
if srcAspect > targetAspect {
cropW := int(float64(srcH)*targetAspect + 0.5)
if cropW < 1 {
cropW = 1
}
x0 := bounds.Min.X + (srcW-cropW)/2
crop = image.Rect(x0, bounds.Min.Y, x0+cropW, bounds.Max.Y)
} else if srcAspect < targetAspect {
cropH := int(float64(srcW)/targetAspect + 0.5)
if cropH < 1 {
cropH = 1
}
y0 := bounds.Min.Y + (srcH-cropH)/2
crop = image.Rect(bounds.Min.X, y0, bounds.Max.X, y0+cropH)
}
scale := float64(cell.Dx()) / float64(crop.Dx())
var scaler draw.Scaler = draw.CatmullRom
if scale < imageResizeCheapScaleRatio {
scaler = draw.ApproxBiLinear
}
scaler.Scale(dst, cell, src, crop, draw.Src, nil)
}
// writeFileAtomic 先写临时文件再 rename,避免并发的读取方读到半个文件。
func writeFileAtomic(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".cover-*")
if err != nil {
return err
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
_ = os.Remove(tmp.Name())
return err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmp.Name())
return err
}
if err := os.Rename(tmp.Name(), path); err != nil {
_ = os.Remove(tmp.Name())
return err
}
return nil
}
// LibraryCoverSourcesForLibrary 是给 handler 的便捷封装:库存在且没有自定
// 义封面时返回成员海报来源;远程挂载库由各自的上游负责封面,不在此生成。
func LibraryCoverSourcesForLibrary(ctx context.Context, repos *repository.Container, libraryID string) ([]string, *model.Library) {
if repos == nil || repos.Library == nil {
return nil, nil
}
libraryID = strings.TrimSpace(libraryID)
if libraryID == "" {
return nil, nil
}
lib, err := repos.Library.FindByID(ctx, libraryID)
if err != nil || lib == nil {
return nil, nil
}
if strings.TrimSpace(lib.CoverURL) != "" {
return nil, lib
}
return LibraryCoverSources(ctx, repos, libraryID, libraryCoverSourceLimit), lib
}
+335
View File
@@ -0,0 +1,335 @@
package service
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func solidJPEG(t *testing.T, c color.Color, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}
func decodeJPEG(t *testing.T, data []byte) image.Image {
t.Helper()
img, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode composed cover: %v", err)
}
return img
}
// TestComposeLibraryCoverSplitsIntoSideBySideColumns 保证多张海报是竖切成
// 左右分栏(左→右顺序),而不是 2×2 田字格。
func TestComposeLibraryCoverSplitsIntoSideBySideColumns(t *testing.T) {
images := [][]byte{
solidJPEG(t, color.RGBA{R: 220, A: 255}, 200, 300),
solidJPEG(t, color.RGBA{G: 220, A: 255}, 200, 300),
solidJPEG(t, color.RGBA{B: 220, A: 255}, 200, 300),
solidJPEG(t, color.RGBA{R: 220, G: 220, A: 255}, 200, 300),
}
out, err := composeLibraryCover(images, 480, 270)
if err != nil {
t.Fatalf("compose: %v", err)
}
img := decodeJPEG(t, out)
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
t.Fatalf("size = %v, want 480x270", img.Bounds())
}
// 四张图 → 四条竖栏;同一栏从顶部到底部颜色一致(竖切,不是上下分割)。
y := img.Bounds().Dy() / 2
checks := []struct {
name string
x int
dominant func(color.RGBA) bool
}{
{"column 1 red", 60, func(c color.RGBA) bool { return c.R > c.G+40 && c.R > c.B+40 }},
{"column 2 green", 180, func(c color.RGBA) bool { return c.G > c.R+40 && c.G > c.B+40 }},
{"column 3 blue", 300, func(c color.RGBA) bool { return c.B > c.R+40 && c.B > c.G+40 }},
{"column 4 yellow", 420, func(c color.RGBA) bool { return c.R > c.B+40 && c.G > c.B+40 }},
}
for _, check := range checks {
for _, yy := range []int{2, y, img.Bounds().Dy() - 3} {
r, g, b, _ := img.At(check.x, yy).RGBA()
got := color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 255}
if !check.dominant(got) {
t.Fatalf("%s at y=%d: pixel = %v", check.name, yy, got)
}
}
}
}
// TestComposeLibraryCoverHandlesFewerPosters 覆盖 1/2/3 张成员的布局。
func TestComposeLibraryCoverHandlesFewerPosters(t *testing.T) {
cases := []struct {
name string
count int
}{
{"single", 1},
{"pair", 2},
{"triple", 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
images := make([][]byte, 0, tc.count)
for i := 0; i < tc.count; i++ {
images = append(images, solidJPEG(t, color.RGBA{R: 200, G: 120, A: 255}, 300, 450))
}
out, err := composeLibraryCover(images, 320, 180)
if err != nil {
t.Fatalf("compose: %v", err)
}
img := decodeJPEG(t, out)
if img.Bounds().Dx() != 320 || img.Bounds().Dy() != 180 {
t.Fatalf("size = %v, want 320x180", img.Bounds())
}
})
}
}
// TestComposeLibraryCoverSkipsUndecodableSources 保证一张坏图不会让整张
// 封面失败:其余可解码的成员仍然会拼出来。
func TestComposeLibraryCoverSkipsUndecodableSources(t *testing.T) {
images := [][]byte{
[]byte("not an image"),
solidJPEG(t, color.RGBA{G: 200, A: 255}, 200, 300),
}
out, err := composeLibraryCover(images, 240, 135)
if err != nil {
t.Fatalf("compose: %v", err)
}
if len(out) == 0 {
t.Fatal("expected composed cover bytes")
}
if _, err := composeLibraryCover([][]byte{[]byte("nope")}, 240, 135); err == nil {
t.Fatal("expected error when no source is decodable")
}
}
func TestLibraryCoverCanvasKeepsSixteenByNine(t *testing.T) {
cases := []struct {
inW, inH int
wantW int
wantH int
checkRatio bool
}{
{0, 0, 480, 270, true},
{640, 480, 640, 360, true}, // 4:3 盒子 → 装进 16:9
{2000, 0, 1920, 1080, true}, // 超上限 → 夹紧
{40, 0, 96, 54, true}, // 低于下限 → 抬到最小宽度
{0, 540, 960, 540, false}, // 只给高度 → 按 16:9 推宽度
}
for _, tc := range cases {
w, h := libraryCoverCanvas(tc.inW, tc.inH)
if w != tc.wantW || h != tc.wantH {
t.Fatalf("libraryCoverCanvas(%d,%d) = %dx%d, want %dx%d", tc.inW, tc.inH, w, h, tc.wantW, tc.wantH)
}
if tc.checkRatio && w != 0 && h != 0 {
ratio := float64(w) / float64(h)
if ratio < 1.7 || ratio > 1.85 {
t.Fatalf("ratio for %dx%d = %.3f, want ~1.78", tc.inW, tc.inH, ratio)
}
}
}
}
// TestLibraryCoverSourcesDedupesSeries 保证封面不会两张都是同一部剧的不同集,
// 并且优先取海报、没有海报时退回剧照、完全没有图的成员被跳过。
func TestLibraryCoverSourcesDedupesSeries(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)
}
rows := []model.Media{
{Base: model.Base{ID: "a1"}, LibraryID: lib.ID, Title: "A", Path: "/media/tv/A/S01E01.mkv", SeriesID: "series-a", PosterURL: "https://img/a1.jpg"},
{Base: model.Base{ID: "a2"}, LibraryID: lib.ID, Title: "A", Path: "/media/tv/A/S01E02.mkv", SeriesID: "series-a", PosterURL: "https://img/a2.jpg"},
{Base: model.Base{ID: "b1"}, LibraryID: lib.ID, Title: "B", Path: "/media/tv/B/S01E01.mkv", SeriesID: "series-b", PosterURL: "https://img/b1.jpg"},
{Base: model.Base{ID: "c1"}, LibraryID: lib.ID, Title: "C", Path: "/media/tv/C/S01E01.mkv", SeriesID: "series-c", BackdropURL: "https://img/c1-backdrop.jpg"},
{Base: model.Base{ID: "d1"}, LibraryID: lib.ID, Title: "D", Path: "/media/tv/D/S01E01.mkv", SeriesID: "series-d"},
}
for i := range rows {
if err := svc.repo.DB.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
// 竖切三栏:一部剧只出一张,无图成员被跳过 → 正好 3 张。
sources := LibraryCoverSources(t.Context(), svc.repo, lib.ID, libraryCoverSourceLimit)
if len(sources) != 3 {
t.Fatalf("sources = %v, want 3 (一部剧一张、跳过无图成员)", sources)
}
seen := map[string]bool{}
for _, src := range sources {
if seen[src] {
t.Fatalf("duplicate source in %v", sources)
}
seen[src] = true
}
// 同一部剧只会出现一次:具体保留哪一集由查询顺序决定,但绝不能两张。
if seen["https://img/a1.jpg"] && seen["https://img/a2.jpg"] {
t.Fatalf("same series contributed two posters: %v", sources)
}
if !seen["https://img/a1.jpg"] && !seen["https://img/a2.jpg"] {
t.Fatalf("series A is missing from sources: %v", sources)
}
if !seen["https://img/c1-backdrop.jpg"] {
t.Fatalf("member without poster should fall back to backdrop: %v", sources)
}
}
// TestLibraryCoverImageCachesComposedBytes 验证生成结果会落盘复用:同一份
// 内容第二次请求不再回源。
func TestLibraryCoverImageCachesComposedBytes(t *testing.T) {
var hits int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(solidJPEG(t, color.RGBA{R: 180, G: 140, B: 90, A: 255}, 240, 360))
}))
defer upstream.Close()
parsed, err := url.Parse(upstream.URL)
if err != nil {
t.Fatalf("parse upstream: %v", err)
}
cacheCfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
proxy := NewImageProxy(cacheCfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
sources := []string{upstream.URL + "/1.jpg", upstream.URL + "/2.jpg", upstream.URL + "/3.jpg", upstream.URL + "/4.jpg"}
first, err := proxy.LibraryCoverImage(t.Context(), "lib-1", sources, 480, 270)
if err != nil {
t.Fatalf("first cover: %v", err)
}
if img := decodeJPEG(t, first); img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
t.Fatalf("size = %v, want 480x270", img.Bounds())
}
hitsAfterFirst := atomic.LoadInt32(&hits)
if hitsAfterFirst != 4 {
t.Fatalf("upstream hits = %d, want one per source", hitsAfterFirst)
}
second, err := proxy.LibraryCoverImage(t.Context(), "lib-1", sources, 480, 270)
if err != nil {
t.Fatalf("second cover: %v", err)
}
if !bytes.Equal(first, second) {
t.Fatal("cached cover bytes differ from the first build")
}
if got := atomic.LoadInt32(&hits); got != hitsAfterFirst {
t.Fatalf("cache miss: upstream hits %d -> %d", hitsAfterFirst, got)
}
// 内容变了要重新生成(成员海报换了新文件)。已缓存的三张海报不会重下,
// 只有新增的那张会回源。
changed := append([]string{}, sources[:3]...)
changed = append(changed, upstream.URL+"/5.jpg")
if _, err := proxy.LibraryCoverImage(t.Context(), "lib-1", changed, 480, 270); err != nil {
t.Fatalf("cover after content change: %v", err)
}
if got := atomic.LoadInt32(&hits); got != hitsAfterFirst+1 {
t.Fatalf("content change should rebuild with one new fetch, hits = %d", got)
}
// 同一批成员的另一个尺寸档位要能共存:Web/App 与 Emby 请求的尺寸不同,
// 不能互相把对方的成品当「旧内容」删掉。
if _, err := proxy.LibraryCoverImage(t.Context(), "lib-1", changed, 400, 225); err != nil {
t.Fatalf("other size cover: %v", err)
}
entries, err := os.ReadDir(LibraryCoverCacheDir(cacheCfg.Cache.CacheDir))
if err != nil {
t.Fatalf("read cover cache dir: %v", err)
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
files = append(files, entry.Name())
}
}
if len(files) != 2 {
t.Fatalf("expected 480x270 and 400x225 variants to coexist, got %v", files)
}
}
// TestLibraryCoverSourceBytesReadsLocalSidecarPoster 覆盖刮削把海报存成
// /media/... 本地绝对路径的情况:这类来源没有 http(s) host,不能走 Fetch,
// 必须按库根目录白名单直接读盘,否则整张拼图会因为「没有可解码的海报」而
// 生成失败(生产环境所有本地库都是这种来源)。
func TestLibraryCoverSourceBytesReadsLocalSidecarPoster(t *testing.T) {
libDir := t.TempDir()
posterPath := filepath.Join(libDir, "poster.jpg")
if err := os.WriteFile(posterPath, solidJPEG(t, color.RGBA{R: 180, G: 90, A: 255}, 200, 300), 0o644); err != nil {
t.Fatalf("write poster: %v", err)
}
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
// 未登记为库根目录时必须拒绝读取。
if _, err := proxy.coverSourceBytes(t.Context(), posterPath); err == nil {
t.Fatal("expected local path outside the library roots to be rejected")
}
proxy.SetLibraryRootsProvider(func() []string { return []string{libDir} })
data, err := proxy.coverSourceBytes(t.Context(), posterPath)
if err != nil {
t.Fatalf("read local poster: %v", err)
}
if len(data) == 0 {
t.Fatal("expected poster bytes")
}
if _, ok := decodeCoverImage(data); !ok {
t.Fatal("local poster should decode")
}
}
// TestLibraryCoverSourcesForLibrarySkipsCustomCover 保证管理员填了自定义
// 封面时不会再生成拼图(沿用自定义图)。
func TestLibraryCoverSourcesForLibrarySkipsCustomCover(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", CoverURL: "https://example.com/custom.jpg"}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := svc.repo.DB.Create(&model.Media{
Base: model.Base{ID: "m1"},
LibraryID: lib.ID,
Title: "M",
Path: "/media/movies/m1.mkv",
PosterURL: "https://img/m1.jpg",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
sources, found := LibraryCoverSourcesForLibrary(t.Context(), svc.repo, lib.ID)
if found == nil || found.CoverURL != lib.CoverURL {
t.Fatalf("library = %#v, want the existing library", found)
}
if len(sources) != 0 {
t.Fatalf("expected no generated sources with a custom cover, got %v", sources)
}
if _, blank := LibraryCoverSourcesForLibrary(t.Context(), svc.repo, "missing"); blank != nil {
t.Fatalf("unknown library should return nil, got %#v", blank)
}
}
+165
View File
@@ -0,0 +1,165 @@
package service
import (
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func newManualMarkFixture(t *testing.T) (*MediaSegmentService, *repository.Container, *model.Media) {
t.Helper()
repos := repository.New(newServiceTestDB(t))
svc := NewMediaSegmentService(zap.NewNop(), repos)
m := &model.Media{
Base: model.Base{ID: "ep-1"},
Title: "Show",
Path: "/tv/show/s01e01.mkv",
LibraryID: "lib-1",
SeasonNum: 1,
EpisodeNum: 1,
}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
return svc, repos, m
}
func TestSetManualMarkWritesIntroBoundary(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
if err := svc.SetManualMark(t.Context(), m, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 90_000,
}); err != nil {
t.Fatalf("set mark: %v", err)
}
rows, err := repos.MediaSegment.ListByMedia(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("rows = %#v, want 1", rows)
}
if rows[0].Kind != model.SegmentKindIntro || rows[0].Source != SegmentSourceManual {
t.Fatalf("row = %#v, want manual intro", rows[0])
}
if rows[0].StartMs != 0 || rows[0].EndMs != 90_000 {
t.Fatalf("row = %#v, want 0-90000ms", rows[0])
}
}
func TestSetManualMarkClearRemovesOnlyThatKind(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
ctx := t.Context()
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindIntro, PositionMs: 90_000}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindCredits, PositionMs: 1_200_000}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindIntro, Clear: true}); err != nil {
t.Fatal(err)
}
rows, err := repos.MediaSegment.ListByMedia(ctx, m.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Kind != model.SegmentKindCredits {
t.Fatalf("rows = %#v, want only the credits mark to survive", rows)
}
}
func TestSetManualMarkRejectsInvalidRequests(t *testing.T) {
svc, _, m := newManualMarkFixture(t)
cases := []ManualMarkRequest{
{Kind: "recap", PositionMs: 1000}, // 只支持 intro/credits
{Kind: "preview", PositionMs: 1000}, // 同上
{Kind: model.SegmentKindIntro, PositionMs: 0},
{Kind: model.SegmentKindIntro, PositionMs: -5},
}
for _, req := range cases {
if err := svc.SetManualMark(t.Context(), m, req); err == nil {
t.Fatalf("req %#v: want an error", req)
}
}
}
// 同一季的其它集应自动继承手动标记,但用户已显式标过的集不能被覆盖。
func TestSetManualMarkPropagatesToSeasonSiblings(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
ctx := t.Context()
sibling := &model.Media{
Base: model.Base{ID: "ep-2"},
Title: "Show",
Path: "/tv/show/s01e02.mkv",
LibraryID: "lib-1",
SeasonNum: 1,
EpisodeNum: 2,
}
other := &model.Media{
Base: model.Base{ID: "ep-3"},
Title: "Show",
Path: "/tv/show/s01e03.mkv",
LibraryID: "lib-1",
SeasonNum: 1,
EpisodeNum: 3,
}
if err := repos.DB.Create(sibling).Error; err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(other).Error; err != nil {
t.Fatal(err)
}
// other 已经有自己的手动片头标记,不该被覆盖。
if err := svc.SetManualMark(ctx, other, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 45_000,
}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 90_000,
}); err != nil {
t.Fatal(err)
}
sibRows, err := repos.MediaSegment.ListByMedia(ctx, sibling.ID)
if err != nil {
t.Fatal(err)
}
if len(sibRows) != 1 || sibRows[0].EndMs != 90_000 {
t.Fatalf("sibling rows = %#v, want the propagated 90000ms mark", sibRows)
}
otherRows, err := repos.MediaSegment.ListByMedia(ctx, other.ID)
if err != nil {
t.Fatal(err)
}
if len(otherRows) != 1 || otherRows[0].EndMs != 45_000 {
t.Fatalf("other rows = %#v, want its own 45000ms mark preserved", otherRows)
}
}
// 手动标记优先级最高:即使外部源给了不同位置,播放时也只输出手动那条。
func TestManualMarkOutranksExternalSource(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
ctx := t.Context()
if err := repos.MediaSegment.ReplaceForMedia(ctx, m.ID, IntroDBSource, []model.MediaSegment{{
MediaID: m.ID, Kind: model.SegmentKindIntro, StartMs: 0, EndMs: 30_000, Source: IntroDBSource,
}}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 90_000,
}); err != nil {
t.Fatal(err)
}
rows, err := svc.mergeForPlayback(ctx, m.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Source != SegmentSourceManual || rows[0].EndMs != 90_000 {
t.Fatalf("rows = %#v, want the manual mark to win", rows)
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ func TestManualAdultMatchUsesSelectedMetaTubeDetailsAndRealBackdrop(t *testing.T
if detailCalls.Load() != 1 {
t.Fatalf("selected MetaTube detail calls = %d, want 1", detailCalls.Load())
}
wantPoster := upstream.URL + "/v1/images/primary/AVE/94600?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
wantPoster := upstream.URL + "/v1/images/primary/AVE/94600?auto=false&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
if match.PosterURL != wantPoster {
t.Fatalf("selected poster = %q, want %q", match.PosterURL, wantPoster)
}
+16
View File
@@ -179,6 +179,22 @@ func (s *MediaService) libraryCardsObjectKey(libraryID string, visibility MediaV
return s.seriesCardsCacheKey(libraryID, visibility) + ":obj"
}
// 版本分组对象的缓存 key。
//
// 详情页的「同片多版本」以前每次都现算:条目没有 series_id / tmdb / douban /
// thetvdb 时走不到收窄分支,会直接把这个库最多 5000 行整行读出来再逐条算版本
// 键(实测 5000 行的库单次 ~180ms,且没有缓存)。同一个版本组的结果只随扫描/
// 刮削变化,所以按「合并库集合 + 版本组键」缓存,配合 media: 前缀一起被
// invalidateMediaCache 清掉。
func (s *MediaService) versionSiblingsCacheKey(libraryIDs []string, groupKey string) string {
libs := append([]string(nil), libraryIDs...)
sort.Strings(libs)
return "media:obj:version-siblings:" + hashObjectCacheKey([]string{
strings.Join(libs, ","),
groupKey,
})
}
func (s *MediaService) invalidateMediaCache(ctx context.Context) {
if s != nil && s.cache != nil {
s.cache.DeletePrefix(ctx, "media:")
+30
View File
@@ -255,6 +255,35 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
if len(libraryIDs) == 0 {
libraryIDs = []string{media.LibraryID}
}
// 同一个版本组的结果对组内任一成员都成立,而且只随扫描/刮削变化:缓存它
// 才能让「逐部点开详情」的浏览不再每次都重扫该库最多 5000 行。
cacheKey := s.versionSiblingsCacheKey(libraryIDs, key)
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.([]model.Media); ok {
// GetObject 返回的对象此后视为不可变,调用方(详情页)可能重排/追加
// 切片,所以拷贝一份再交出去。
out := make([]model.Media, len(cached))
copy(out, cached)
return out, nil
}
}
}
matched, err := s.loadVersionSiblings(ctx, media, libraryIDs, key)
if err != nil {
return nil, err
}
if s.cache != nil {
s.cache.SetObject(cacheKey, matched, s.derivedReadCacheTTL())
}
out := make([]model.Media, len(matched))
copy(out, matched)
return out, nil
}
// loadVersionSiblings 是版本分组的慢路径(可能读全库),结果由
// listVersionSiblings 缓存。
func (s *MediaService) loadVersionSiblings(ctx context.Context, media *model.Media, libraryIDs []string, key string) ([]model.Media, error) {
filter := repository.MediaQueryFilter{IncludeNSFW: true}
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
@@ -263,6 +292,7 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
// 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的
// 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。
var candidates []model.Media
var err error
narrowed := false
hasAdultCode := mediaAdultGroupCode(*media) != "" ||
canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != ""
+315 -29
View File
@@ -6,6 +6,7 @@ package service
import (
"context"
"errors"
"strings"
"time"
@@ -43,9 +44,20 @@ type SegmentView struct {
}
// ToSegmentViews 转换库内行为对外视图。
//
// 顺手丢掉明显不可能的区间:库里的历史数据可能是在 end_ms: 0 被误当成「到片尾」
// 的时期写下的(那会把整集变成片头),这些行如果原样下发,播放器仍然会整集弹
// 跳过按钮。只有片尾类允许开放结束点(EndMs == 0),其余要求 EndMs > StartMs。
func ToSegmentViews(rows []model.MediaSegment) []SegmentView {
out := make([]SegmentView, 0, len(rows))
for _, row := range rows {
openEnded := row.Kind == model.SegmentKindCredits || row.Kind == model.SegmentKindPreview
if row.StartMs < 0 {
continue
}
if row.EndMs <= row.StartMs && !(openEnded && row.EndMs <= 0) {
continue
}
out = append(out, SegmentView{Kind: row.Kind, StartMs: row.StartMs, EndMs: row.EndMs})
}
return out
@@ -53,16 +65,36 @@ func ToSegmentViews(rows []model.MediaSegment) []SegmentView {
// MediaSegmentService 负责把外部片头片尾数据补齐到本地并供播放器读取。
type MediaSegmentService struct {
log *zap.Logger
repo *repository.Container
introdb *IntroDBService
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用 TheIntroDB。
log *zap.Logger
repo *repository.Container
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用外部源。
probe *MediaProbeService
// prewarmGap 是预热两次 IntroDB 请求之间的间隔;测试可设为 0。
// prewarmGap 是预热两次请求之间的间隔;测试可设为 0。
prewarmGap time.Duration
now func() time.Time
// providers 是已启用的外部片段源,顺序即注册顺序。
// 由设置项 segment.source 决定实际使用哪一个,见 activeProvider。
providers []SegmentProvider
// imdb 用于给只认 IMDb id 的源(IntroDB)解析外部 id。未注入时该源被跳过。
imdb IMDbIDResolver
}
// IMDbIDResolver 把 TMDb 条目解析成 IMDb id(ttXXXXXXX)。
// 由 TMDbProvider 实现;抽出接口是为了让 service 层不直接依赖配置与 API key。
type IMDbIDResolver interface {
ResolveIMDbID(ctx context.Context, tmdbID int, isMovie bool) (string, error)
}
// 片段数据源设置项。取值形如 off / theintrodb / introdb / todb,
// 与播放器档案里的「自动跳过」是两件独立的事。
const SegmentSourceSettingKey = "segment.source"
// SegmentSourceOff 表示不查询任何外部源,只消费已落库的数据(手动标记等)。
const SegmentSourceOff = "off"
// SegmentSourceOptions 是设置页可选的源标识。
var SegmentSourceOptions = []string{SegmentSourceOff, IntroDBSource, IntroDBAppSource, TODbSource}
// NewMediaSegmentService is the constructor.
func NewMediaSegmentService(log *zap.Logger, repo *repository.Container) *MediaSegmentService {
return &MediaSegmentService{
@@ -81,14 +113,98 @@ func (s *MediaSegmentService) SetPrewarmGap(gap time.Duration) *MediaSegmentServ
return s
}
// SetIntroDB wires the provider. Without it the service only reads cached rows.
// SetIntroDB wires the TheIntroDB provider and registers it as a source.
func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService {
if s != nil {
s.introdb = p
s.registerProvider(p)
}
return s
}
// SetIntroDBApp wires the IntroDB (introdb.app) provider.
func (s *MediaSegmentService) SetIntroDBApp(p *IntroDBAppService) *MediaSegmentService {
if s != nil {
s.registerProvider(p)
}
return s
}
// SetTODb wires the TODb provider.
func (s *MediaSegmentService) SetTODb(p *TODbService) *MediaSegmentService {
if s != nil {
s.registerProvider(p)
}
return s
}
// SetIMDbResolver wires the TMDb-backed IMDb id resolver used by sources that
// only address media by IMDb id.
func (s *MediaSegmentService) SetIMDbResolver(r IMDbIDResolver) *MediaSegmentService {
if s != nil {
s.imdb = r
}
return s
}
// registerProvider adds a provider once, keeping registration order as the
// fallback order.
func (s *MediaSegmentService) registerProvider(p SegmentProvider) {
if s == nil || p == nil {
return
}
for _, existing := range s.providers {
if existing.Source() == p.Source() {
return
}
}
s.providers = append(s.providers, p)
}
// providerBySource returns the registered provider with that source tag.
func (s *MediaSegmentService) providerBySource(source string) SegmentProvider {
for _, p := range s.providers {
if p.Source() == source {
return p
}
}
return nil
}
// activeProvider 选出本次查询要用的源。
//
// 设置项没配置时沿用历史行为(TheIntroDB),避免升级后功能凭空消失;
// 显式配置成 off 才真正停用外部查询。
func (s *MediaSegmentService) activeProvider(ctx context.Context) SegmentProvider {
if s == nil || len(s.providers) == 0 {
return nil
}
source := s.configuredSource(ctx)
if source == "" {
return s.providerBySource(IntroDBSource)
}
if source == SegmentSourceOff {
return nil
}
if p := s.providerBySource(source); p != nil {
return p
}
// 配了一个不认识的源(多半是降级回滚后的残留设置):退回默认源,
// 让功能继续可用,而不是静默失效。
return s.providerBySource(IntroDBSource)
}
// configuredSource reads the operator's source selection.
func (s *MediaSegmentService) configuredSource(ctx context.Context) string {
if s == nil || s.repo == nil || s.repo.Setting == nil {
return ""
}
v, err := s.repo.Setting.Get(ctx, SegmentSourceSettingKey)
if err != nil {
return ""
}
return strings.ToLower(strings.TrimSpace(v))
}
// 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 {
@@ -114,7 +230,7 @@ func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Medi
// 写库,若在本次写事务还没结束时启动,两个写事务会抢同一把锁(SQLite 下就是
// SQLITE_BUSY,实测能直接把社区库的落库打失败)。
defer s.ensureMediaProbe(ctx, m)
return s.introDBSegments(ctx, m)
return s.communitySegments(ctx, m)
}
// ensureMediaProbe 在还没探过(或上次失败已过冷却期)时起一次异步探测。
@@ -147,10 +263,15 @@ 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)
// communitySegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
// 返回值是按来源优先级合并后的结果(manual > 外部源 > propagated)。
func (s *MediaSegmentService) communitySegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
provider := s.activeProvider(ctx)
// 没选任何外部源(off):只读本地已有数据,绝不发起外网请求。
if provider == nil {
return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID)
}
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, provider.Source())
if err != nil {
// 调用方预算耗尽时仍读本地缓存,绝不能让播放接口报错。
if ctx.Err() != nil {
@@ -159,7 +280,7 @@ func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Medi
return nil, err
}
if ledger == nil || !ledgerFresh(ledger) {
if _, _, err := s.refresh(ctx, m); err != nil {
if _, _, err := s.refreshWithProvider(ctx, m, provider); err != nil {
// 社区库不可达或返回异常:沿用已有缓存,不影响播放;也不写负缓存。
logIntroDBFailure(s.log, 0, err)
}
@@ -178,13 +299,22 @@ func (s *MediaSegmentService) mergeForPlayback(ctx context.Context, mediaID stri
}
// segmentSourcePriority 数值越大越优先。未知来源视为最低,避免挡住已知源。
//
// 顺序体现用户意图的强度:
//
// manual 用户在当前集显式标记
// propagated 用户在同季其它集标记后继承来的值(仍然是人给的,优先于社区库)
// 外部社区库 自动抓取,最容易被覆盖
//
// 外部社区库之间刻意同级:用户一次只会启用其中一个,历史数据可能来自另一个,
// 同级时按区间起点排序展示即可,不必让旧源压住新源。
func segmentSourcePriority(source string) int {
switch source {
case SegmentSourceManual:
return 3
case IntroDBSource:
return 2
case SegmentSourcePropagated:
return 2
case IntroDBSource, IntroDBAppSource, TODbSource:
return 1
default:
return 0
@@ -224,16 +354,17 @@ func (s *MediaSegmentService) debug(message, mediaID string, err error) {
s.log.Debug(message, zap.String("media_id", mediaID), zap.Error(err))
}
// refresh 向提供方查询并落库,返回 (rows, 是否真的发起过查询, error)。
// refreshWithProvider 向指定来源查询并落库,返回 (rows, 是否真的发起过查询, error)。
//
// attempted=false 表示这部媒体缺少可查询的外部 ID(最常见的原因是还没刮削,
// 剧集也还没关联到 Series),此时刻意不写负缓存:等元数据补齐后下次播放就能查到。
func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) {
if s.introdb == nil {
// 剧集也还没关联到 Series),或者该源需要的 id 类型拿不到(IntroDB 要 IMDb),
// 此时刻意不写负缓存:等元数据补齐后下次播放就能查到。
func (s *MediaSegmentService) refreshWithProvider(ctx context.Context, m *model.Media, provider SegmentProvider) ([]model.MediaSegment, bool, error) {
if s == nil || provider == nil || m == nil {
return nil, false, nil
}
tmdbID, season, episode := s.queryIDs(ctx, m)
if tmdbID <= 0 {
query, ok := s.segmentQuery(ctx, m, provider)
if !ok {
return nil, false, nil
}
// 用脱离请求的 context:客户端可能在抓取完成前就离开了播放页,但结果仍然
@@ -256,10 +387,11 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), budget)
defer cancel()
spans, err := s.introdb.Fetch(fetchCtx, tmdbID, season, episode)
spans, err := provider.FetchSegments(fetchCtx, query)
if err != nil {
return nil, true, err
}
source := provider.Source()
rows := make([]model.MediaSegment, 0, len(spans))
for _, span := range spans {
rows = append(rows, model.MediaSegment{
@@ -268,15 +400,15 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
Kind: span.Kind,
StartMs: span.StartMs,
EndMs: span.EndMs,
Source: IntroDBSource,
Source: source,
})
}
if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, IntroDBSource, rows); err != nil {
if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, source, rows); err != nil {
return nil, true, err
}
if err := s.repo.MediaSegment.UpsertFetch(fetchCtx, &model.MediaSegmentFetch{
MediaID: m.ID,
Source: IntroDBSource,
Source: source,
FetchedAt: time.Now(),
Found: len(rows) > 0,
}); err != nil {
@@ -286,6 +418,38 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
return rows, true, nil
}
// refresh 保留旧签名(预热任务与测试使用),走当前选中的源。
func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) {
return s.refreshWithProvider(ctx, m, s.activeProvider(ctx))
}
// segmentQuery 组装该源需要的身份参数。返回 false 表示这个源缺少必要 ID,
// 本次不必发请求。
func (s *MediaSegmentService) segmentQuery(ctx context.Context, m *model.Media, provider SegmentProvider) (SegmentQuery, bool) {
tmdbID, season, episode := s.queryIDs(ctx, m)
isMovie := season <= 0 && episode <= 0
q := SegmentQuery{TMDbID: tmdbID, Season: season, Episode: episode, IsMovie: isMovie}
switch provider.Source() {
case IntroDBAppSource:
// IntroDB 只认 IMDb id,必须经 TMDb external_ids 解析。
if s.imdb == nil || tmdbID <= 0 {
return q, false
}
imdbID, err := s.imdb.ResolveIMDbID(ctx, tmdbID, isMovie)
if err != nil || imdbID == "" {
// 解析不出 IMDb id 不是错误:这部片可能本来就没有,或 TMDb 不可达。
// 返回 false 让本次跳过,但不写负缓存,下次还有机会。
s.debug("resolve imdb id failed", m.ID, err)
return q, false
}
q.IMDbID = imdbID
return q, true
default:
return q, tmdbID > 0
}
}
// propagateIntroToSeason 把本集 IntroDB 命中的 intro 复制到同季还没有
// theintrodb intro 的兄弟集。片尾/预告不传播:各集时长与片尾位置经常不同。
func (s *MediaSegmentService) propagateIntroToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
@@ -347,9 +511,131 @@ func hasSegmentKind(rows []model.MediaSegment, kind string) bool {
return false
}
// Prewarm 批量向 TheIntroDB 补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
// ManualMarkRequest 是「标记片头/片尾」的入参。
//
// PositionMs 是用户点击时所在的播放位置:标记片头时它是片头结束点,
// 标记片尾时它是片尾开始点。
type ManualMarkRequest struct {
Kind string
PositionMs int64
// Clear 为 true 表示清除该类型的标记,PositionMs 被忽略。
Clear bool
}
// ErrInvalidManualMark 表示请求本身不合法(类型的取值或位置越界)。
var ErrInvalidManualMark = errors.New("invalid manual skip mark")
// SetManualMark 写入或清除一条手动标记,并按季传播片头/片尾。
//
// 手动标记的优先级最高(见 segmentSourcePriority),所以用户标过一次之后,
// 外部源即使给出不同位置也不会覆盖它。清除时只删 manual 来源的行,外部数据
// 会自然回落到优先队列里。
func (s *MediaSegmentService) SetManualMark(ctx context.Context, m *model.Media, req ManualMarkRequest) error {
if s == nil || s.repo == nil || m == nil || m.ID == "" {
return ErrInvalidManualMark
}
kind := normalizeManualMarkKind(req.Kind)
if kind == "" {
return ErrInvalidManualMark
}
if !req.Clear && req.PositionMs < 0 {
return ErrInvalidManualMark
}
rows := []model.MediaSegment{}
if !req.Clear {
if req.PositionMs <= 0 {
// 位置 0 的标记没有意义:片头在 0 秒结束等于没有片头。
return ErrInvalidManualMark
}
rows = append(rows, model.MediaSegment{
MediaID: m.ID,
SeriesID: m.SeriesID,
Kind: kind,
StartMs: manualMarkStart(kind),
EndMs: req.PositionMs,
Source: SegmentSourceManual,
})
}
if err := s.repo.MediaSegment.ReplaceForMediaSourceKind(ctx, m.ID, SegmentSourceManual, kind, rows); err != nil {
return err
}
if len(rows) > 0 {
s.propagateManualMarkToSeason(ctx, m, rows)
}
return nil
}
// normalizeManualMarkKind 把外部传入的类型收敛到库内 kind。
// 只接受片头与片尾:回顾/预告没有对应的用户操作入口。
func normalizeManualMarkKind(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case model.SegmentKindIntro:
return model.SegmentKindIntro
case model.SegmentKindCredits:
return model.SegmentKindCredits
default:
return ""
}
}
// manualMarkStart 给出该类型区间的起点:片头从 0 开始,片尾的开始点就是用户
// 标记的位置(所以这里填 0,真正的起点在 EndMs 里由调用方传入)。
func manualMarkStart(kind string) int64 { return 0 }
// propagateManualMarkToSeason 把手动标记复制到同季还没有「显式手动标记」的兄弟集。
//
// 同一季的 OP/ED 通常一致,标一集即可覆盖整季——这是报告里最实用的行为。
// 关键约束:传播值必须写成 SegmentSourcePropagated 而不是 manual,这样才能区分
// 「用户显式标过的集」与「继承来的值」。否则先标 A 集会让 B 集看起来也是手动标
// 过的,之后用户再标 B 集时反而无法覆盖它(或反过来,B 集的传播值挡住 A 集的
// 新标记),两种顺序都会得出错误结果。
func (s *MediaSegmentService) propagateManualMarkToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
if s == nil || s.repo == nil || m == nil || m.SeasonNum <= 0 || len(rows) == 0 {
return
}
siblings, err := s.repo.Media.ListSeasonSiblings(ctx, m)
if err != nil {
s.debug("list season siblings for manual mark failed", m.ID, err)
return
}
for i := range siblings {
sib := &siblings[i]
// 用户显式标过的集保持原样;继承值可以被新的显式标记覆盖。
explicit, err := s.repo.MediaSegment.ListByMediaSource(ctx, sib.ID, SegmentSourceManual)
if err != nil {
s.debug("list sibling manual segments failed", sib.ID, err)
continue
}
if hasSegmentKind(explicit, rows[0].Kind) {
continue
}
copied := make([]model.MediaSegment, 0, len(rows))
for _, row := range rows {
copied = append(copied, model.MediaSegment{
MediaID: sib.ID,
SeriesID: sib.SeriesID,
Kind: row.Kind,
StartMs: row.StartMs,
EndMs: row.EndMs,
Source: SegmentSourcePropagated,
})
}
if err := s.repo.MediaSegment.ReplaceForMediaSourceKind(ctx, sib.ID, SegmentSourcePropagated, rows[0].Kind, copied); err != nil {
s.debug("propagate manual mark failed", sib.ID, err)
}
}
}
// Prewarm 批量补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
//
// 只预热当前启用的源:用户把源切成 off 时不该继续打外网,切换源之后旧源的
// 账本也不再需要刷新。
func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, error) {
if s == nil || s.repo == nil || s.introdb == nil {
if s == nil || s.repo == nil || len(s.providers) == 0 {
return 0, nil
}
provider := s.activeProvider(ctx)
if provider == nil {
return 0, nil
}
if limit <= 0 {
@@ -360,7 +646,7 @@ func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, erro
now = s.now()
}
candidates, err := s.repo.MediaSegment.ListPrewarmCandidates(
ctx, IntroDBSource, now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
ctx, provider.Source(), now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
)
if err != nil {
return 0, err
@@ -371,7 +657,7 @@ func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, erro
return attempted, err
}
m := &candidates[i]
_, did, err := s.refresh(ctx, m)
_, did, err := s.refreshWithProvider(ctx, m, provider)
if err != nil {
logIntroDBFailure(s.log, m.TMDbID, err)
}
+7 -4
View File
@@ -385,7 +385,9 @@ func TestListForPlaybackRespectsCallerDeadline(t *testing.T) {
}
}
func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing.T) {
// 优先级体现「用户意图强度」:manual > propagated > 社区库。
// 传播值来自用户在同季其它集的手动标记,是人给的,必须压过自动抓取的数据。
func TestPreferSegmentsBySourcePrefersManualThenPropagatedThenIntroDB(t *testing.T) {
rows := []model.MediaSegment{
{Kind: model.SegmentKindIntro, StartMs: 1, EndMs: 2, Source: SegmentSourcePropagated},
{Kind: model.SegmentKindIntro, StartMs: 10, EndMs: 20, Source: IntroDBSource},
@@ -395,13 +397,14 @@ func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing
}
got := preferSegmentsBySource(rows)
if len(got) != 2 {
t.Fatalf("got %#v, want manual intro + introdb credits", got)
t.Fatalf("got %#v, want manual intro + propagated 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])
// 片尾没有 manual,propagated 应当压过社区库。
if got[1].Source != SegmentSourcePropagated || got[1].StartMs != 1000 {
t.Fatalf("credits = %#v, want propagated", got[1])
}
}
+73
View File
@@ -787,3 +787,76 @@ func TestMediaVersionGroupKeyDistinguishesHalfEpisode(t *testing.T) {
t.Fatalf("plain episode key = %q, want suffix 1:11", plainKey)
}
}
// TestGetMediaItemCachesVersionSiblings 覆盖详情页最贵的一次查询:
// 条目没有外部 ID 时会整库读最多 5000 行来找同片版本,而结果只随扫描/刮削
// 变化。这里断言第二次打开详情走缓存(删掉候选行后仍能拿到同一组版本),
// 并且调用方修改返回切片不会污染缓存。
func TestGetMediaItemCachesVersionSiblings(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "电影", Path: "/movies", Type: "movie"}
if err := db.Create(&lib).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
rows := []model.Media{
{
Base: model.Base{ID: "v-1080", CreatedAt: now, UpdatedAt: now},
LibraryID: lib.ID,
Title: "影片 2024 1080p",
Path: "/movies/影片.2024.1080p.mkv",
Width: 1920,
Height: 1080,
Year: 2024,
},
{
Base: model.Base{ID: "v-2160", CreatedAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Hour)},
LibraryID: lib.ID,
Title: "影片 2024 2160p",
Path: "/movies/影片.2024.2160p.mkv",
Width: 3840,
Height: 2160,
Year: 2024,
},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
t.Cleanup(func() { _ = svc.cache.Close() })
item, err := svc.GetMediaItem(t.Context(), "v-1080")
if err != nil {
t.Fatal(err)
}
if len(item.Versions) != 2 {
t.Fatalf("versions = %d, want 2: %#v", len(item.Versions), item.Versions)
}
// 把数据删掉:如果第二次还去查库,就只会剩下代表条目自己。
if err := db.Where("id = ?", "v-2160").Delete(&model.Media{}).Error; err != nil {
t.Fatal(err)
}
second, err := svc.GetMediaItem(t.Context(), "v-1080")
if err != nil {
t.Fatal(err)
}
if len(second.Versions) != 2 {
t.Fatalf("cached versions = %d, want 2", len(second.Versions))
}
// 调用方乱改返回的切片不能影响缓存内容。
second.Versions[0] = model.Media{Base: model.Base{ID: "hacked"}}
third, err := svc.GetMediaItem(t.Context(), "v-1080")
if err != nil {
t.Fatal(err)
}
for _, v := range third.Versions {
if v.ID == "hacked" {
t.Fatal("caller mutation leaked into the cached version list")
}
}
}
+10 -4
View File
@@ -352,7 +352,7 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query
year := parseYearFromDate(res.ReleaseDate)
posterSource := firstNonEmpty(res.BigCoverURL, res.CoverURL, res.BigThumbURL, res.ThumbURL)
posterURL, backdropURL := metaTubeArtworkURLs(cfg, res.Provider, res.ID, posterSource)
posterURL, backdropURL := metaTubeArtworkURLs(cfg, res.Provider, res.ID, posterSource, code)
if posterURL == "" {
posterURL = firstNonEmpty(res.BigThumbURL, res.ThumbURL, res.BigCoverURL, res.CoverURL)
}
@@ -396,7 +396,7 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me
year := parseYearFromDate(movie.ReleaseDate)
posterSource := firstNonEmpty(movie.BigCoverURL, movie.CoverURL, movie.BigThumbURL, movie.ThumbURL)
posterURL, backdropURL := metaTubeArtworkURLs(cfg, movie.Provider, movie.ID, posterSource)
posterURL, backdropURL := metaTubeArtworkURLs(cfg, movie.Provider, movie.ID, posterSource, code)
if posterURL == "" {
posterURL = firstNonEmpty(movie.BigThumbURL, movie.ThumbURL, movie.BigCoverURL, movie.CoverURL)
}
@@ -440,7 +440,13 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *Me
}
}
func metaTubeArtworkURLs(cfg MetaTubeConfig, provider, id, posterSource string) (string, string) {
// metaTubeArtworkURLs 构造 MetaTube 图片代理地址。
//
// auto(服务端人脸定位)只能对特殊番号(FC2 / HEYZO / 素人 / 无码)开启,这与 MetaTube
// 官方插件的默认行为一致:常规 DVD 番号的封套是 [封底 | 书脊 | 封面] 横图,封底那半张是样本
// 剧照拼贴、密布人脸,服务端的人脸主簇会落到拼贴/书脊上,裁出来就是「半张封底」。
// 常规番号固定 pos=1(最右侧封面面板)才能拿到正确封面。
func metaTubeArtworkURLs(cfg MetaTubeConfig, provider, id, posterSource, code string) (string, string) {
serverURL := strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
provider = strings.TrimSpace(provider)
id = strings.TrimSpace(id)
@@ -459,7 +465,7 @@ func metaTubeArtworkURLs(cfg MetaTubeConfig, provider, id, posterSource string)
}
if cfg.CropCover {
values.Set("pos", "1")
values.Set("auto", "true")
values.Set("auto", strconv.FormatBool(adultCoverNeedsFaceDetection(code)))
} else {
values.Set("pos", "-1")
values.Set("auto", "false")
+30 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
@@ -137,7 +138,8 @@ func TestMetaTubeProviderGetMovie(t *testing.T) {
if match.Overview != "超绝美少女相沢みなみ出道作品!" {
t.Errorf("unexpected overview: %s", match.Overview)
}
if want := server.URL + "/v1/images/primary/javdb/123456?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fbig_cover.jpg"; match.PosterURL != want {
// IPX-235 是常规 DVD 番号:服务端不得开启人脸定位(auto=false),否则主簇会落到封底的剧照拼贴。
if want := server.URL + "/v1/images/primary/javdb/123456?auto=false&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fbig_cover.jpg"; match.PosterURL != want {
t.Errorf("poster URL = %q, want %q", match.PosterURL, want)
}
if want := server.URL + "/v1/images/backdrop/javdb/123456?quality=90"; match.BackdropURL != want {
@@ -187,3 +189,30 @@ func TestMetaTubeProviderTestConnection(t *testing.T) {
t.Fatalf("expected failure for wrong token, got %v", resFail)
}
}
// MetaTube 代理的 auto 参数决定服务端是否用 Pigo 做人脸定位裁切。
// 常规 DVD 番号的封套封底是剧照拼贴、密布人脸,开启 auto 会裁出「半张封底」;
// 只有 FC2 / HEYZO / 素人 / 无码这类构图不固定的封面才应该开启。
func TestMetaTubeArtworkURLsFaceDetectionPolicy(t *testing.T) {
cfg := MetaTubeConfig{ServerURL: "https://mt.example", CropCover: true}
cases := []struct {
code string
wantAuto string
}{
{"IPX-235", "false"},
{"SSNI-677", "false"},
{"NGOD-361", "false"},
{"FC2-PPV-482896", "true"},
{"HEYZO-1234", "true"},
{"123456-789", "true"},
}
for _, c := range cases {
poster, _ := metaTubeArtworkURLs(cfg, "javdb", "123456", "https://example.com/pl.jpg", c.code)
if !strings.Contains(poster, "auto="+c.wantAuto) {
t.Errorf("%s: poster URL = %q, want auto=%s", c.code, poster, c.wantAuto)
}
if !strings.Contains(poster, "pos=1") {
t.Errorf("%s: poster URL = %q, want pos=1", c.code, poster)
}
}
}
+73
View File
@@ -193,6 +193,79 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi
return items, nil
}
// RecentHistoryWithoutMedia 返回最近观看记录,但完全不 join media。
//
// 详情页解析「继续播放」只需要 media_id/position_ms,带整条 media 会让 300 条
// 响应大一个数量级。旧实现是先用 RecentHistory 把 media 全部查出来(挂载条目
// 还会逐条串行请求上游 Emby),最后才在 handler 里把 media 置空 —— 也就是
// 响应变小了,服务端一点没省。这里直接跳过 hydrate:
//
// - 本地条目:用 (id, library_id, nsfw) 窄列查询判断可见性,不读 overview /
// path / 海报这些大字段;
// - 挂载条目:按挂载媒体库的 ACL 判断,不再发上游请求(上游慢或挂了时,
// 详情页的续播解析不会被它拖住);
// - 记录还在但 media 已删除:保留。与旧行为一致 —— 旧实现 hydrate 失败时
// 同样保留这条历史,客户端只拿 media_id 判断续播目标。
func (p *PlaybackService) RecentHistoryWithoutMedia(ctx context.Context, userID string, limit int, visibility MediaVisibility) ([]HistoryItem, error) {
rows, err := p.repo.History.ListByUser(ctx, userID, limit)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return []HistoryItem{}, nil
}
mediaIDs := make([]string, 0, len(rows))
for i := range rows {
if rows[i].MediaID != "" {
mediaIDs = append(mediaIDs, rows[i].MediaID)
}
}
// 只取可见性判定真正需要的三列。Select 到 model.Media 上是本仓库既有写法
// (见 database/backfillLibraryRootMedia),未选中的字段保持零值。
visibleByID := make(map[string]bool, len(mediaIDs))
if len(mediaIDs) > 0 {
var lean []model.Media
if err := p.repo.DB.WithContext(ctx).Model(&model.Media{}).
Select("id", "library_id", "nsfw").
Where("id IN ?", mediaIDs).
Find(&lean).Error; err != nil {
return nil, err
}
for i := range lean {
visibleByID[lean[i].ID] = visibility.Allows(&lean[i])
}
}
// 挂载库 ACL 每个挂载只解析一次:ResolveMount 只读本地表,不发网络请求。
mountAllowed := make(map[string]bool, 2)
items := make([]HistoryItem, 0, len(rows))
for i := range rows {
mediaID := rows[i].MediaID
if allowed, known := visibleByID[mediaID]; known {
if allowed {
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
}
continue
}
if p.remote != nil && IsEmbyRemoteID(mediaID) {
mountID, _, _ := DecodeEmbyRemoteID(mediaID)
allowed, resolved := mountAllowed[mountID]
if !resolved {
mount, _, resolveErr := p.remote.ResolveMount(ctx, mountID)
// 挂载解析不出来时无法判定,沿用旧行为保留这条历史。
allowed = resolveErr != nil || mount == nil || EmbyMountLibraryAllowed(visibility, mount)
mountAllowed[mountID] = allowed
}
if !allowed {
continue
}
}
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
}
return items, nil
}
// ─── Favourites ─────────────────────────────────────────────────────────────
// ToggleFavourite flips the favourite flag and reports the new state.
+62
View File
@@ -113,3 +113,65 @@ func TestPlaybackProgressCompletedThreshold(t *testing.T) {
})
}
}
// TestRecentHistoryWithoutMediaSkipsHydration 覆盖详情页用的轻量历史路径:
// 不返回 media,但可见性过滤必须与带 media 的版本一致。
func TestRecentHistoryWithoutMediaSkipsHydration(t *testing.T) {
db := newServiceTestDB(t, &model.PlaybackHistory{}, &model.Media{}, &model.Library{})
repos := repository.New(db)
svc := NewPlaybackService(zap.NewNop(), repos)
seed := func(id, libraryID string, nsfw bool) {
t.Helper()
m := model.Media{
Base: model.Base{ID: id},
LibraryID: libraryID,
Title: "标题 " + id,
Path: "/media/" + id + ".mkv",
NSFW: nsfw,
}
if err := db.Create(&m).Error; err != nil {
t.Fatalf("seed media %s: %v", id, err)
}
}
seed("visible", "lib-ok", false)
seed("adult", "lib-ok", true)
seed("hidden-lib", "lib-hidden", false)
now := time.Now()
for i, mediaID := range []string{"visible", "adult", "hidden-lib", "gone"} {
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "hist-" + mediaID},
UserID: "user-1",
MediaID: mediaID,
PositionMs: 10_000,
DurationMs: 60_000,
WatchedAt: now.Add(-time.Duration(i) * time.Minute),
}).Error; err != nil {
t.Fatalf("seed history %s: %v", mediaID, err)
}
}
items, err := svc.RecentHistoryWithoutMedia(context.Background(), "user-1", 50, MediaVisibility{
HiddenLibraryIDs: []string{"lib-hidden"},
})
if err != nil {
t.Fatalf("RecentHistoryWithoutMedia: %v", err)
}
got := make([]string, 0, len(items))
for _, item := range items {
if item.Media != nil {
t.Fatalf("expected no media hydration, got %#v", item.Media)
}
got = append(got, item.MediaID)
}
want := []string{"visible", "gone"}
if len(got) != len(want) {
t.Fatalf("media ids = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("media ids = %v, want %v", got, want)
}
}
}
@@ -0,0 +1,730 @@
package reader
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// 远端音轨的本地落盘缓存(媒体代理 /api/reader/media 使用)。
//
// 背景:听书源的音频直链签在腾讯音乐 CDN 上,跨境链路会间歇性返回 401
// 或直接 RST 连接。旧行为是每次请求(首播、拖动进度、播放器重试)都重新
// 回源,一次抖动就把错误交给播放器。这里按「书 + 媒体对象」把整段音频
// 落盘(丢掉 ?vkey=... 这类会轮换的签名参数),命中后由 http.ServeContent
// 按 Range 下发:首播成功之后不再依赖 CDN,拖动进度也是本地读取。
const (
audioMediaDirName = "reader-audio"
audioMediaTTL = 7 * 24 * time.Hour
// audioMediaMaxBytes 音频缓存的总容量上限。超限时按 mtime 淘汰最旧的。
//
// 这个池不参与 cache_cleanup 的全局总量统计(hls/images/library-covers
// 三池),而是在每次下载成功后自行淘汰,见 pruneAudioMediaCache。
audioMediaMaxBytes = 1 << 30 // 1GiB
audioMediaPrunePeriod = 10 * time.Minute
audioMediaPruneKeepPct = 90
// audioPartialSuffix 未下完的音轨分片后缀。
//
// 分片跨请求保留:首播失败后用户再点一次、或播放器重连,都能用 Range 接着下,
// 不必从第 0 字节重来。分片名带 .part 后缀,不会被 OpenCachedRemoteAudio 当成
// 完整音轨下发。
audioPartialSuffix = ".part"
// audioMediaObjectMaxBytes 单个音频对象的落盘上限。有声书一章约 3–5MB,
// 给到 64MB 足够容纳长章节,也能挡住误当成音频的超大对象。
audioMediaObjectMaxBytes = 64 << 20
// audioDownloadRetryDelay 断点续传重试的退避基数,实际等待为 (attempt-1) 倍。
audioDownloadRetryDelay = 500 * time.Millisecond
)
var (
audioMediaPruneMu sync.Mutex
audioMediaPrunedAt time.Time
)
// audioMediaDir 音频缓存根目录(与转码缓存同一套目录约定)。
func (s *ReaderService) audioMediaDir() string {
if s == nil || s.cfg == nil {
return ""
}
base := strings.TrimSpace(s.cfg.Cache.CacheDir)
if base == "" {
if dataDir := strings.TrimSpace(s.cfg.App.DataDir); dataDir != "" {
base = filepath.Join(dataDir, "cache")
}
}
if base == "" {
return ""
}
return filepath.Join(base, audioMediaDirName)
}
// audioMediaVolatileParams 是换票时会变、但与「这是哪段音频」无关的 query 参数。
// 缓存键必须忽略它们,否则每次换票都打不中缓存。
//
// 不在这张表里的参数一律保留:有些书源的直链形如
// https://host/audio?id=12345,路径固定、靠 query 区分章节,把 query 整个丢掉
// 会让第二集命中第一集的缓存,直接播错集。
var audioMediaVolatileParams = map[string]bool{
"vkey": true, "uid": true, "ct": true, "t": true, "ts": true,
"token": true, "sign": true, "signature": true, "sig": true,
"expires": true, "expire": true, "expiry": true, "auth": true,
"nonce": true, "rand": true, "rnd": true, "r": true, "guid": true,
"ftime": true,
}
// audioMediaCacheKey 缓存键:书 + 媒体对象身份。
//
// 身份 = scheme://host/path + 非签名类 query。签名/时效参数(vkey、token、
// 时间戳等)被剔除,保证换票后仍命中同一条缓存;其余参数保留,避免
// 「路径相同、只靠 query 区分」的书源串集。
func audioMediaCacheKey(bookID, mediaURL string) string {
u, err := url.Parse(strings.TrimSpace(mediaURL))
if err != nil || u.Host == "" {
return ""
}
ident := strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host) + u.EscapedPath()
if u.RawQuery != "" {
if q, parseErr := url.ParseQuery(u.RawQuery); parseErr == nil {
var kept []string
for k, vs := range q {
if audioMediaVolatileParams[strings.ToLower(k)] {
continue
}
for _, v := range vs {
kept = append(kept, k+"="+v)
}
}
if len(kept) > 0 {
sort.Strings(kept)
ident += "?" + strings.Join(kept, "&")
}
}
}
sum := sha256.Sum256([]byte(bookID + "|" + ident))
return hex.EncodeToString(sum[:16])
}
// RemoteAudioCachePath 返回该音轨的缓存文件路径;无法缓存时返回空串。
func (s *ReaderService) RemoteAudioCachePath(bookID, mediaURL string) string {
key := audioMediaCacheKey(bookID, mediaURL)
if key == "" {
return ""
}
dir := s.audioMediaDir()
if dir == "" {
return ""
}
return filepath.Join(dir, key[:2], key+".audio")
}
// IsAudioMediaURL 判断媒体代理这次要拉的是不是音轨。
//
// 后缀优先:音频书籍里也会出现封面/插图(.jpg)或 HLS 播放列表(.m3u8),
// 这些不能当成音轨整段落盘。只有后缀为空(签名直链常见形态)时才退回按
// 书籍类型判断。
func IsAudioMediaURL(rawURL string, bookType int) bool {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return bookType == 1
}
ext := strings.ToLower(filepath.Ext(u.Path))
if ext == "" {
return bookType == 1
}
return audioExtensions[ext]
}
// HasCachedRemoteAudio 判断该音轨是否已有可用缓存(会顺带刷新 mtime)。
func (s *ReaderService) HasCachedRemoteAudio(bookID, mediaURL string) bool {
f, _, ok := s.OpenCachedRemoteAudio(bookID, mediaURL)
if ok {
_ = f.Close()
}
return ok
}
// HasPartialRemoteAudio 是否有可续传的未完成分片(.part)。
//
// 调用方据此决定首播策略:有分片说明上一次已经下到一半(用户点过重试、
// 或播放器自己重连过),此时「下载完再下发」能透明续上剩余字节;
// 没有分片才是真正的冷启动,边下边发换更低的 TTFB。
func (s *ReaderService) HasPartialRemoteAudio(bookID, mediaURL string) bool {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" {
return false
}
info, err := os.Stat(path + audioPartialSuffix)
if err != nil || !info.Mode().IsRegular() || info.Size() == 0 {
return false
}
return info.Size() <= audioMediaObjectMaxBytes
}
// OpenCachedRemoteAudio 打开已缓存的音轨(命中返回打开的文件,调用方负责关闭)。
// 过期的缓存顺手删掉,未过期则刷新 mtime 供 LRU 淘汰。
func (s *ReaderService) OpenCachedRemoteAudio(bookID, mediaURL string) (*os.File, os.FileInfo, bool) {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" {
return nil, nil, false
}
f, err := os.Open(path) // #nosec G304 -- 路径由服务端哈希生成
if err != nil {
return nil, nil, false
}
info, err := f.Stat()
if err != nil || info.IsDir() || info.Size() == 0 {
_ = f.Close()
return nil, nil, false
}
if info.ModTime().Add(audioMediaTTL).Before(time.Now()) {
_ = f.Close()
_ = os.Remove(path)
return nil, nil, false
}
now := time.Now()
_ = os.Chtimes(path, now, now)
return f, info, true
}
// errAudioObjectTooLarge 上游对象超过单章缓存上限(不是可重试的错误)。
var errAudioObjectTooLarge = errors.New("音源超过缓存上限")
// NoRetryError 标记「换着花样再试也不会有进展」的失败(例如上游正成批拒绝
// 签名票、书源根本给不出新票)。带断点续传的下载器收到它会立刻停止,并把
// Err 原样交给调用方——继续重试只会让播放器多等十几秒,还会让对方更坚定地
// 拒绝我们。
type NoRetryError struct{ Err error }
func (e *NoRetryError) Error() string { return e.Err.Error() }
func (e *NoRetryError) Unwrap() error { return e.Err }
// AudioRangeFetcher 取一次上游音轨响应。
//
// rangeHeader 为空表示整段请求;否则形如 "bytes=N-",用于从断点续传。
// head 是调用方已经嗅探读过、必须补写回文件的前缀(只有整段请求才有)。
// 调用方负责在内部完成换票;这里只看状态码和字节。
type AudioRangeFetcher func(rangeHeader string) (resp *http.Response, head []byte, err error)
// AudioStreamStart 首播直通开始的回调:调用方在这里写响应头与状态码。
// total 是上游声明的总长度(分块传输时为 -1),contentType 是上游给的类型。
type AudioStreamStart func(total int64, contentType string)
// errAudioStreamCannotRestart 直通模式下遇到必须「从第 0 字节重来」的情况。
//
// 直通时客户端已经收到了前缀字节,不可能让它把已收到的丢掉重听,所以这类
// 分支只能中止这次响应(让客户端判成不完整),并把已下载的分片留给下次续传。
var errAudioStreamCannotRestart = errors.New("直通中无法从零重新下载")
// audioSinkError 标记「写客户端失败」(通常是播放器关掉了连接)。
// 与写盘失败区分开:写盘失败说明本地有问题,写客户端失败只需停止下载。
type audioSinkError struct{ err error }
func (e *audioSinkError) Error() string { return "写入客户端失败: " + e.err.Error() }
func (e *audioSinkError) Unwrap() error { return e.err }
// DownloadRemoteAudio 带断点续传地把整段音轨写进缓存,返回最终文件路径。
//
// 为什么需要续传:腾讯 CDN 的跨境边缘常在传输中途 RST,旧实现每次重试都从
// 第 0 字节重下,一章 3MB 白下几次就把首播拖到几十秒。这里把已写入的字节留在
// <cache>.part 里,下一次用 Range: bytes=N- 续上;分片跨请求保留,用户点重试
// 或播放器重连都能接着下。
//
// 上游忽略 Range 直接回 200、或分片区间对不上时,丢弃分片从头重来。
// 下载过程中的失败不会留下「看起来完整」的缓存文件:只有字节数与上游声明的
// 总长一致才会原子改名到正式路径。
func (s *ReaderService) DownloadRemoteAudio(
ctx context.Context,
bookID, mediaURL string,
attempts int,
fetch AudioRangeFetcher,
) (string, error) {
return s.downloadRemoteAudio(ctx, bookID, mediaURL, attempts, fetch, nil, nil)
}
// DownloadRemoteAudioStreaming 首播直通:一边落盘一边把字节写给 sink。
//
// 与 DownloadRemoteAudio 的区别只有一处——不再等整段下完才返回,第一个字节
// 到达就通过 onStart 写好响应头并开始外发,慢链路上的首播不必先等 3–5MB。
//
// 代价是失败语义变严:客户端已经收到前缀字节,任何「必须从 0 重来」的情况
// (上游忽略 Range 回 200、分片区间对不上、416)都只能中止这次响应,
// 不能像普通模式那样静默重下。已写入的分片仍然保留,下次可续传。
func (s *ReaderService) DownloadRemoteAudioStreaming(
ctx context.Context,
bookID, mediaURL string,
attempts int,
fetch AudioRangeFetcher,
onStart AudioStreamStart,
sink io.Writer,
) (string, error) {
if sink == nil {
return "", errors.New("直通下载未提供输出目标")
}
return s.downloadRemoteAudio(ctx, bookID, mediaURL, attempts, fetch, onStart, sink)
}
// downloadRemoteAudio 是两种下载模式的共同实现。sink 非空即首播直通模式。
func (s *ReaderService) downloadRemoteAudio(
ctx context.Context,
bookID, mediaURL string,
attempts int,
fetch AudioRangeFetcher,
onStart AudioStreamStart,
sink io.Writer,
) (string, error) {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" {
return "", errors.New("音频缓存目录未配置")
}
if fetch == nil {
return "", errors.New("音频下载器未配置")
}
if attempts <= 0 {
attempts = 1
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return "", fmt.Errorf("创建音频缓存目录失败: %w", err)
}
partPath := path + audioPartialSuffix
var offset int64
if info, statErr := os.Stat(partPath); statErr == nil && info.Mode().IsRegular() {
offset = info.Size()
// 分片太旧或超过上限:续传没有意义,直接丢掉重下。
if offset > audioMediaObjectMaxBytes || info.ModTime().Add(audioMediaTTL).Before(time.Now()) {
_ = os.Remove(partPath)
offset = 0
}
}
file, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return "", fmt.Errorf("创建音频缓存文件失败: %w", err)
}
// onStart 只应触发一次:直通模式下响应头只能写一次。
started := false
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
_ = file.Close()
return "", ctx.Err()
case <-time.After(audioDownloadRetryDelay * time.Duration(attempt-1)):
}
}
rangeHeader := ""
if offset > 0 {
rangeHeader = fmt.Sprintf("bytes=%d-", offset)
}
resp, head, fetchErr := fetch(rangeHeader)
if fetchErr != nil {
var noRetry *NoRetryError
if errors.As(fetchErr, &noRetry) {
_ = file.Close()
// 已经写下的分片留着:上游恢复后用户再点一次能接着续。
if offset == 0 {
_ = os.Remove(partPath)
}
return "", noRetry.Err
}
lastErr = fetchErr
continue
}
if resp == nil {
lastErr = errors.New("音源响应为空")
continue
}
expected := int64(-1)
switch resp.StatusCode {
case http.StatusPartialContent:
start, total, ok := parseContentRange(resp.Header.Get("Content-Range"))
if !ok || offset == 0 || start != offset {
drainAndClose(resp.Body)
if sink != nil && offset > 0 {
// 已经发给客户端 offset 字节:重来会让客户端收到重复内容。
// 分片保持原样,下次请求从这里续。
_ = file.Close()
return "", errAudioStreamCannotRestart
}
offset = 0
_ = file.Truncate(0)
lastErr = fmt.Errorf("音源返回的分片区间不可用(%q)", resp.Header.Get("Content-Range"))
continue
}
expected = total
case http.StatusOK:
// 上游忽略了 Range(或者本来就是整段请求):只能从第 0 字节写。
if offset > 0 {
drainAndClose(resp.Body)
if sink != nil {
// 同上:已发出去的字节不能撤回,分片留着供下次续传。
_ = file.Close()
return "", errAudioStreamCannotRestart
}
offset = 0
if truncErr := file.Truncate(0); truncErr != nil {
lastErr = fmt.Errorf("重置音频缓存失败: %w", truncErr)
continue
}
}
expected = resp.ContentLength
case http.StatusRequestedRangeNotSatisfiable:
// 分片比上游对象还长(对象换过):丢掉分片重来。
drainAndClose(resp.Body)
if sink != nil && offset > 0 {
_ = file.Close()
return "", errAudioStreamCannotRestart
}
offset = 0
_ = file.Truncate(0)
lastErr = fmt.Errorf("音源分片已失效(%d)", resp.StatusCode)
continue
default:
drainAndClose(resp.Body)
lastErr = fmt.Errorf("音源返回 %d", resp.StatusCode)
continue
}
if sink != nil && !started {
// 直通模式下要在这里就把状态码与 Content-Length 写出去,客户端才能
// 边收边播。写头的失败(客户端已断开)当作停止下载处理。
if onStart != nil {
onStart(expected, resp.Header.Get("Content-Type"))
}
started = true
}
if _, seekErr := file.Seek(offset, io.SeekStart); seekErr != nil {
drainAndClose(resp.Body)
lastErr = fmt.Errorf("音频缓存定位失败: %w", seekErr)
continue
}
var written int64
var writeErr error
if sink != nil {
written, writeErr = writeAudioChunkStream(file, sink, head, resp.Body, audioMediaObjectMaxBytes-offset)
} else {
written, writeErr = writeAudioChunk(file, head, resp.Body, audioMediaObjectMaxBytes-offset)
}
_ = resp.Body.Close()
offset += written
if errors.Is(writeErr, errAudioObjectTooLarge) {
_ = file.Close()
_ = os.Remove(partPath)
return "", fmt.Errorf("已重试 %d 次: %w", attempts, writeErr)
}
var sinkErr *audioSinkError
if errors.As(writeErr, &sinkErr) {
// 客户端不要了:停止下载,已写入的分片留着供下次续传。
_ = file.Close()
return "", sinkErr
}
if writeErr != nil {
// 已写入的字节保留在分片里,下一轮从新的断点续传。
lastErr = writeErr
continue
}
if expected >= 0 && offset != expected {
lastErr = fmt.Errorf("音频写入不完整(%d/%d 字节)", offset, expected)
continue
}
if offset == 0 {
lastErr = errors.New("音源内容为空")
continue
}
if truncErr := file.Truncate(offset); truncErr != nil {
_ = file.Close()
return "", fmt.Errorf("写入音频缓存失败: %w", truncErr)
}
if closeErr := file.Close(); closeErr != nil {
return "", fmt.Errorf("写入音频缓存失败: %w", closeErr)
}
if renameErr := os.Rename(partPath, path); renameErr != nil {
return "", fmt.Errorf("写入音频缓存失败: %w", renameErr)
}
s.pruneAudioMediaCache()
return path, nil
}
_ = file.Close()
if lastErr == nil {
lastErr = errors.New("音源拉取失败")
}
if offset > 0 {
return "", fmt.Errorf("已重试 %d 次: %w(已缓存 %d 字节,重试可续传)", attempts, lastErr, offset)
}
return "", fmt.Errorf("已重试 %d 次: %w", attempts, lastErr)
}
// writeAudioChunk 先把已读前缀补写回文件,再把响应体写到 limit 字节为止。
// 返回本次实际写入的字节数(出错时也包含已写成功的部分)。
func writeAudioChunk(w io.Writer, head []byte, body io.Reader, limit int64) (int64, error) {
var total int64
if len(head) > 0 {
n, err := w.Write(head)
total += int64(n)
if err != nil {
return total, err
}
}
if limit <= 0 {
return total, errAudioObjectTooLarge
}
n, err := io.Copy(w, io.LimitReader(body, limit-total+1))
total += n
if err != nil {
return total, err
}
if total > limit {
return total, errAudioObjectTooLarge
}
return total, nil
}
// writeAudioChunkStream 同 writeAudioChunk,但同时把字节写给客户端(首播直通)。
//
// 写盘失败要让整个下载停下来(本地状态不可信,继续发字节等于给客户端一份
// 服务端存不下的内容);写客户端失败只说明接收方不要了,用 audioSinkError
// 标出来让上层保留分片、安静收尾。两者必须区分,否则播放器一关就把分片删了。
func writeAudioChunkStream(w io.Writer, sink io.Writer, head []byte, body io.Reader, limit int64) (int64, error) {
var total int64
writeBoth := func(p []byte) error {
n, err := w.Write(p)
total += int64(n)
if err != nil {
return err
}
if n > 0 {
if _, sinkErr := sink.Write(p[:n]); sinkErr != nil {
return &audioSinkError{err: sinkErr}
}
}
return nil
}
if len(head) > 0 {
if err := writeBoth(head); err != nil {
return total, err
}
}
if limit <= 0 {
return total, errAudioObjectTooLarge
}
buf := make([]byte, 32<<10)
remaining := limit - total + 1
for {
if remaining <= 0 {
return total, errAudioObjectTooLarge
}
chunk := buf
if int64(len(chunk)) > remaining {
chunk = chunk[:remaining]
}
n, readErr := body.Read(chunk)
if n > 0 {
if err := writeBoth(chunk[:n]); err != nil {
return total, err
}
remaining -= int64(n)
}
if readErr == io.EOF {
break
}
if readErr != nil {
return total, readErr
}
}
if total > limit {
return total, errAudioObjectTooLarge
}
return total, nil
}
// parseContentRange 解析 "bytes 100-199/1234" 的起点与总长度。
// 总长度未知("*")时 total 为 -1。
func parseContentRange(v string) (start, total int64, ok bool) {
v = strings.TrimSpace(v)
const prefix = "bytes "
if len(v) <= len(prefix) || !strings.EqualFold(v[:len(prefix)], prefix) {
return 0, -1, false
}
v = strings.TrimSpace(v[len(prefix):])
slash := strings.IndexByte(v, '/')
if slash < 0 {
return 0, -1, false
}
span, size := v[:slash], strings.TrimSpace(v[slash+1:])
dash := strings.IndexByte(span, '-')
if dash <= 0 {
return 0, -1, false
}
start, err := strconv.ParseInt(strings.TrimSpace(span[:dash]), 10, 64)
if err != nil || start < 0 {
return 0, -1, false
}
if size == "" || size == "*" {
return start, -1, true
}
total, err = strconv.ParseInt(size, 10, 64)
if err != nil || total <= 0 {
return start, -1, true
}
return start, total, true
}
// drainAndClose 丢弃剩余响应体并关闭连接(尽量让上游把连接收回去)。
func drainAndClose(body io.ReadCloser) {
if body == nil {
return
}
_, _ = io.Copy(io.Discard, io.LimitReader(body, 64<<10))
_ = body.Close()
}
// pruneAudioMediaCache 低频清理:删过期文件,超容量时按 mtime 淘汰最旧的。
func (s *ReaderService) pruneAudioMediaCache() {
dir := s.audioMediaDir()
if dir == "" {
return
}
audioMediaPruneMu.Lock()
if time.Since(audioMediaPrunedAt) < audioMediaPrunePeriod {
audioMediaPruneMu.Unlock()
return
}
audioMediaPrunedAt = time.Now()
audioMediaPruneMu.Unlock()
type entry struct {
path string
size int64
mod time.Time
}
var entries []entry
var total int64
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
info, infoErr := d.Info()
if infoErr != nil {
return nil
}
if info.ModTime().Add(audioMediaTTL).Before(time.Now()) {
_ = os.Remove(path)
return nil
}
entries = append(entries, entry{path: path, size: info.Size(), mod: info.ModTime()})
total += info.Size()
return nil
})
if total <= audioMediaMaxBytes {
return
}
sort.Slice(entries, func(i, j int) bool { return entries[i].mod.Before(entries[j].mod) })
target := int64(audioMediaMaxBytes) * audioMediaPruneKeepPct / 100
for _, e := range entries {
if total <= target {
break
}
if err := os.Remove(e.path); err == nil {
total -= e.size
}
}
}
// ─── 区间直通:首播不必等整章下完 ───────────────────────────────────────────
//
// 播放器(media3)取一条 m4a 时会开两次请求:先整段打开嗅探容器,再带
// Range 从目标位置重开。过去「带 Range 的冷缓存请求」只能整章下完再按区间
// 下发,于是每一章的起播都要等 1–4MB 的跨境回源(实测 1.5–35 秒)。下面这
// 三个方法让 handler 能把客户端要的那一段边下边发,同时把字节补进分片。
// PartialRemoteAudio 打开未完成分片(只读),返回文件句柄与当前长度。
//
// 播放器重开同一条音轨时,分片里已经躺着 [0, size) 的字节:区间直通先把这段
// 发出去,再从 size 处回源续下,不必为了一个字节重下整章。
func (s *ReaderService) PartialRemoteAudio(bookID, mediaURL string) (*os.File, int64, bool) {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" {
return nil, 0, false
}
f, err := os.Open(path + audioPartialSuffix)
if err != nil {
return nil, 0, false
}
info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > audioMediaObjectMaxBytes {
_ = f.Close()
return nil, 0, false
}
return f, info.Size(), true
}
// AppendRemoteAudioPartial 以追加方式打开分片,返回打开那一刻的长度。
//
// 调用方必须核对「返回长度 == 它算出的断点」:对不上说明分片已被别的写入者
// 推进过,此时继续写会把字节落到错误位置,宁可放弃落盘只做纯透传。
func (s *ReaderService) AppendRemoteAudioPartial(bookID, mediaURL string) (*os.File, int64, error) {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" {
return nil, 0, errors.New("音频缓存目录未配置")
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, 0, fmt.Errorf("创建音频缓存目录失败: %w", err)
}
f, err := os.OpenFile(path+audioPartialSuffix, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
if err != nil {
return nil, 0, err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, 0, err
}
return f, info.Size(), nil
}
// PromoteRemoteAudioPartial 把已经写满的分片转成正式缓存文件。
//
// 只认「分片长度 == 上游声明的总长」。区间直通是按播放器节奏下发的,中途断开
// 时字节数必然不足;把这种分片当完整缓存下发,播放器会表现为「时长正确但后
// 半段没声音」。
func (s *ReaderService) PromoteRemoteAudioPartial(bookID, mediaURL string, total int64) bool {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" || total <= 0 {
return false
}
partPath := path + audioPartialSuffix
info, err := os.Stat(partPath)
if err != nil || !info.Mode().IsRegular() || info.Size() != total {
return false
}
if err := os.Rename(partPath, path); err != nil {
return false
}
s.pruneAudioMediaCache()
return true
}
// ParseContentRange 解析 "bytes 100-199/1234" 的起点与总长度。
//
// 导出给 handler 用:区间直通要按上游声明的总长自己组装 206 响应头。
func ParseContentRange(v string) (start, total int64, ok bool) {
return parseContentRange(v)
}
@@ -0,0 +1,434 @@
package reader
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/config"
)
func TestIsAudioMediaURL(t *testing.T) {
cases := []struct {
name string
url string
bookType int
want bool
}{
{"m4a 直链", "https://cdn.example.com/a.m4a?uid=1&vkey=x", 0, true},
{"mp3 直链", "https://cdn.example.com/a.MP3", 0, true},
{"音频书里的插图", "https://cdn.example.com/img/cover.jpg", 1, false},
{"HLS 播放列表", "https://cdn.example.com/index.m3u8?token=1", 1, false},
{"无后缀签名直链(音频书)", "https://cdn.example.com/stream?token=1", 1, true},
{"无后缀签名直链(文本/图片书)", "https://cdn.example.com/stream?token=1", 0, false},
{"坏地址但书籍是音频", "://bad", 1, true},
}
for _, c := range cases {
if got := IsAudioMediaURL(c.url, c.bookType); got != c.want {
t.Errorf("%s: IsAudioMediaURL(%q, %d) = %v,期望 %v", c.name, c.url, c.bookType, got, c.want)
}
}
}
// TestAudioMediaCacheKeyIgnoresQuery 换票会改写 uid/vkey/ct,缓存键必须忽略
// 这些签名/时效参数,否则每次换票都会重新下载一遍。
func TestAudioMediaCacheKeyIgnoresQuery(t *testing.T) {
a := audioMediaCacheKey("book-1", "https://cdn.example.com/long/a.m4a?uid=1&vkey=aaa")
b := audioMediaCacheKey("book-1", "https://cdn.example.com/long/a.m4a?uid=2&vkey=bbb&ct=3")
if a == "" || a != b {
t.Fatalf("同一对象换票后缓存键应一致: %q vs %q", a, b)
}
if c := audioMediaCacheKey("book-2", "https://cdn.example.com/long/a.m4a"); c == a {
t.Fatalf("不同书籍的缓存键不应相同")
}
if d := audioMediaCacheKey("book-1", "https://cdn.example.com/long/b.m4a"); d == a {
t.Fatalf("不同对象的缓存键不应相同")
}
}
// TestAudioMediaCacheKeyKeepsIdentifyingQuery 只靠 query 区分章节的书源
// (路径固定、如 /audio?id=12345)不能被合并成同一个缓存对象,否则第二集
// 会命中第一集的缓存直接播错内容。
func TestAudioMediaCacheKeyKeepsIdentifyingQuery(t *testing.T) {
ep1 := audioMediaCacheKey("book-1", "https://host/audio?id=12345&vkey=aaa")
ep2 := audioMediaCacheKey("book-1", "https://host/audio?id=67890&vkey=bbb")
if ep1 == "" || ep2 == "" {
t.Fatal("缓存键不应为空")
}
if ep1 == ep2 {
t.Fatalf("不同章节(query 不同)的缓存键不应相同,否则会串集")
}
}
// TestAudioMediaCacheKeyIgnoresQueryOrder 非签名参数的顺序不应影响缓存键。
func TestAudioMediaCacheKeyIgnoresQueryOrder(t *testing.T) {
a := audioMediaCacheKey("book-1", "https://host/a.m4a?id=7&chapter=3&vkey=x")
b := audioMediaCacheKey("book-1", "https://host/a.m4a?chapter=3&id=7&vkey=y")
if a == "" || a != b {
t.Fatalf("参数顺序不应影响缓存键: %q vs %q", a, b)
}
}
// ─── 断点续传下载器 ──────────────────────────────────────────────────────
func TestParseContentRange(t *testing.T) {
cases := []struct {
in string
start int64
total int64
wantOK bool
}{
{"bytes 100-199/1234", 100, 1234, true},
{"bytes 0-99/*", 0, -1, true},
{"BYTES 500-599/600", 500, 600, true},
{"", 0, -1, false},
{"bytes */1234", 0, -1, false},
{"bytes x-1/2", 0, -1, false},
}
for _, c := range cases {
start, total, ok := parseContentRange(c.in)
if ok != c.wantOK || start != c.start || total != c.total {
t.Errorf("parseContentRange(%q) = (%d,%d,%v),期望 (%d,%d,%v)",
c.in, start, total, ok, c.start, c.total, c.wantOK)
}
}
}
// errAfterReader 读到 data 末尾后返回 err,模拟上游中途 RST/提前 EOF。
type errAfterReader struct {
data []byte
pos int
err error
}
func (r *errAfterReader) Read(p []byte) (int, error) {
if r.pos >= len(r.data) {
return 0, r.err
}
n := copy(p, r.data[r.pos:])
r.pos += n
return n, nil
}
func (r *errAfterReader) Close() error { return nil }
func newAudioDownloadTestService(t *testing.T) *ReaderService {
t.Helper()
return &ReaderService{cfg: &config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}}
}
func okResponse(body []byte) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
ContentLength: int64(len(body)),
Header: http.Header{"Content-Type": {"audio/mp4"}},
Body: io.NopCloser(bytes.NewReader(body)),
}
}
func rangeResponse(header string, body []byte) *http.Response {
return &http.Response{
StatusCode: http.StatusPartialContent,
Header: http.Header{"Content-Range": {header}},
Body: io.NopCloser(bytes.NewReader(body)),
}
}
// TestDownloadRemoteAudioResumesAfterReset 上游写到一半断开后,第二轮必须用
// Range 从断点续传,最终文件与上游内容逐字节一致。
func TestDownloadRemoteAudioResumesAfterReset(t *testing.T) {
svc := newAudioDownloadTestService(t)
const mediaURL = "https://cdn.example.com/long/a.m4a?vkey=1"
payload := bytes.Repeat([]byte("0123456789"), 500) // 5000 字节
const firstChunk = 2000
var ranges []string
attempt := 0
path, err := svc.DownloadRemoteAudio(t.Context(), "book-1", mediaURL, 3,
func(rangeHeader string) (*http.Response, []byte, error) {
ranges = append(ranges, rangeHeader)
attempt++
if attempt == 1 {
// 声明全量长度,只写 2000 字节就断开。
return &http.Response{
StatusCode: http.StatusOK,
ContentLength: int64(len(payload)),
Header: http.Header{"Content-Type": {"audio/mp4"}},
Body: &errAfterReader{
data: payload[:firstChunk],
err: io.ErrUnexpectedEOF,
},
}, nil, nil
}
if rangeHeader != fmt.Sprintf("bytes=%d-", firstChunk) {
t.Fatalf("第二轮应请求断点续传,实际 Range=%q", rangeHeader)
}
return rangeResponse(
fmt.Sprintf("bytes %d-%d/%d", firstChunk, len(payload)-1, len(payload)),
payload[firstChunk:],
), nil, nil
})
if err != nil {
t.Fatalf("续传后应当成功: %v", err)
}
got, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatalf("读缓存文件失败: %v", readErr)
}
if !bytes.Equal(got, payload) {
t.Fatalf("续传后的内容不完整: %d 字节,期望 %d", len(got), len(payload))
}
if len(ranges) != 2 || ranges[0] != "" {
t.Fatalf("请求序列 = %q,期望首次整段、第二次续传", ranges)
}
}
// TestDownloadRemoteAudioResumesAcrossCalls 上一次请求失败留下的分片,下一次
// (用户点重试)要能直接接着下,而不是从第 0 字节重来。
func TestDownloadRemoteAudioResumesAcrossCalls(t *testing.T) {
svc := newAudioDownloadTestService(t)
const mediaURL = "https://cdn.example.com/long/b.m4a"
payload := bytes.Repeat([]byte("abcdefgh"), 1000)
const firstChunk = 3000
// 第一次调用:上游只给 3000 字节就断,重试也没用(每次都 200 + 截断)。
_, err := svc.DownloadRemoteAudio(t.Context(), "book-2", mediaURL, 1,
func(rangeHeader string) (*http.Response, []byte, error) {
return &http.Response{
StatusCode: http.StatusOK,
ContentLength: int64(len(payload)),
Header: http.Header{"Content-Type": {"audio/mp4"}},
Body: &errAfterReader{
data: payload[:firstChunk],
err: io.ErrUnexpectedEOF,
},
}, nil, nil
})
if err == nil {
t.Fatalf("上游截断时应当失败")
}
// 第二次调用:直接请求 bytes=3000-,续完。
var seenRange string
path, err := svc.DownloadRemoteAudio(t.Context(), "book-2", mediaURL, 1,
func(rangeHeader string) (*http.Response, []byte, error) {
seenRange = rangeHeader
return rangeResponse(
fmt.Sprintf("bytes %d-%d/%d", firstChunk, len(payload)-1, len(payload)),
payload[firstChunk:],
), nil, nil
})
if err != nil {
t.Fatalf("跨请求续传应当成功: %v", err)
}
if seenRange != fmt.Sprintf("bytes=%d-", firstChunk) {
t.Fatalf("应当从上次的断点续传,实际 Range=%q", seenRange)
}
got, _ := os.ReadFile(path)
if !bytes.Equal(got, payload) {
t.Fatalf("跨请求续传后的内容不完整: %d 字节", len(got))
}
}
// TestDownloadRemoteAudioRestartsWhenRangeIgnored 上游忽略 Range 仍回 200 时,
// 必须丢掉分片从头写,不能把两段字节拼在一起。
func TestDownloadRemoteAudioRestartsWhenRangeIgnored(t *testing.T) {
svc := newAudioDownloadTestService(t)
const mediaURL = "https://cdn.example.com/long/c.m4a"
payload := bytes.Repeat([]byte("xyz9"), 500)
var ranges []string
attempt := 0
path, err := svc.DownloadRemoteAudio(t.Context(), "book-3", mediaURL, 3,
func(rangeHeader string) (*http.Response, []byte, error) {
ranges = append(ranges, rangeHeader)
attempt++
if attempt == 1 {
return &http.Response{
StatusCode: http.StatusOK,
ContentLength: int64(len(payload)),
Header: http.Header{"Content-Type": {"audio/mp4"}},
Body: &errAfterReader{data: payload[:700], err: io.ErrUnexpectedEOF},
}, nil, nil
}
// 无视 Range,整段回 200。
return okResponse(payload), nil, nil
})
if err != nil {
t.Fatalf("应当成功: %v", err)
}
if ranges[1] == "" {
t.Fatalf("第二轮应当带 Range")
}
got, _ := os.ReadFile(path)
if !bytes.Equal(got, payload) {
t.Fatalf("忽略 Range 后必须从头重写,实际 %d 字节(期望 %d)", len(got), len(payload))
}
}
// TestDownloadRemoteAudioStopsOnNoRetry 上游成批拒绝时(NoRetryError),
// 下载器要立刻收手,不能把同一张被拒的票再打几遍。
func TestDownloadRemoteAudioStopsOnNoRetry(t *testing.T) {
svc := newAudioDownloadTestService(t)
denied := errors.New("音源暂时被上游拒绝(HTTP 401)")
calls := 0
_, err := svc.DownloadRemoteAudio(t.Context(), "book-4", "https://cdn.example.com/long/d.m4a", 4,
func(rangeHeader string) (*http.Response, []byte, error) {
calls++
return nil, nil, &NoRetryError{Err: denied}
})
if err == nil || !strings.Contains(err.Error(), "401") {
t.Fatalf("应原样返回上游拒绝原因,实际 %v", err)
}
if calls != 1 {
t.Fatalf("不可重试的失败只应请求 1 次,实际 %d 次", calls)
}
}
// ─── 首播直通(边下边发) ────────────────────────────────────────────────
// TestDownloadRemoteAudioStreamingForwardsWhileDownloading 直通模式:字节必须
// 在下载过程中就到达 sink,而不是等整段下完才一次性写出去(首播 TTFB)。
func TestDownloadRemoteAudioStreamingForwardsWhileDownloading(t *testing.T) {
svc := newAudioDownloadTestService(t)
const mediaURL = "https://cdn.example.com/long/stream.m4a? vkey=1"
payload := bytes.Repeat([]byte("stream!"), 1000)
var sink bytes.Buffer
var startedTotal int64
// 用「读到一半时 sink 里已经有数据」来证明确实在边下边发。
var midLength int
path, err := svc.DownloadRemoteAudioStreaming(t.Context(), "book-s", mediaURL, 1,
func(rangeHeader string) (*http.Response, []byte, error) {
if rangeHeader != "" {
t.Fatalf("首播直通不应带 Range,实际 %q", rangeHeader)
}
return okResponse(payload), nil, nil
},
func(total int64, contentType string) {
startedTotal = total
// 头部回调必须早于任何字节下发。
if sink.Len() != 0 {
t.Errorf("写响应头时不应已有字节写出")
}
},
writerFunc(func(p []byte) (int, error) {
if midLength == 0 && sink.Len() == 0 && len(p) > 0 {
midLength = sink.Len()
}
return sink.Write(p)
}),
)
if err != nil {
t.Fatalf("直通下载失败: %v", err)
}
if startedTotal != int64(len(payload)) {
t.Fatalf("头部回调应拿到上游总长 %d,实际 %d", len(payload), startedTotal)
}
if !bytes.Equal(sink.Bytes(), payload) {
t.Fatalf("下发的字节不完整: %d 字节,期望 %d", sink.Len(), len(payload))
}
// 下载完成后缓存文件必须完整可命中。
got, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatalf("读缓存文件失败: %v", readErr)
}
if !bytes.Equal(got, payload) {
t.Fatalf("落盘内容不完整: %d 字节", len(got))
}
if !svc.HasCachedRemoteAudio("book-s", mediaURL) {
t.Fatal("直通下完后应当命中缓存")
}
}
// TestDownloadRemoteAudioStreamingStopsWhenClientGoesAway 客户端中途断开时
// 停止下载,但保留分片供下次续传(不能把已下的字节白扔掉)。
func TestDownloadRemoteAudioStreamingStopsWhenClientGoesAway(t *testing.T) {
svc := newAudioDownloadTestService(t)
const mediaURL = "https://cdn.example.com/long/gone.m4a"
payload := bytes.Repeat([]byte("abcdefgh"), 2000)
clientGone := errors.New("client closed connection")
var sink bytes.Buffer
_, err := svc.DownloadRemoteAudioStreaming(t.Context(), "book-g", mediaURL, 1,
func(rangeHeader string) (*http.Response, []byte, error) {
return okResponse(payload), nil, nil
},
nil,
writerFunc(func(p []byte) (int, error) {
// 收下前 1000 字节后模拟播放器断开。
if sink.Len() >= 1000 {
return 0, clientGone
}
room := 1000 - sink.Len()
if len(p) <= room {
return sink.Write(p)
}
n, _ := sink.Write(p[:room])
return n, clientGone
}),
)
if err == nil {
t.Fatal("客户端断开时应当停止下载")
}
var sinkErr *audioSinkError
if !errors.As(err, &sinkErr) {
t.Fatalf("应标记为写客户端失败(便于上层区分),实际 %v", err)
}
// 分片要留着:这是续传的原料。
path := svc.RemoteAudioCachePath("book-g", mediaURL)
if path == "" {
t.Fatal("缓存路径不应为空")
}
if info, statErr := os.Stat(path + audioPartialSuffix); statErr != nil || info.Size() == 0 {
t.Fatalf("断流后应保留 .part 分片,实际 %v", statErr)
}
if svc.HasCachedRemoteAudio("book-g", mediaURL) {
t.Fatal("中断的下载不能留下完整缓存(否则会下发截断音频)")
}
}
// TestDownloadRemoteAudioStreamingAbortsOnRestart 直通模式下上游忽略 Range 回
// 200(需要从 0 重来)时必须中止,而不是把重复字节拼进客户端已收到的流里。
func TestDownloadRemoteAudioStreamingAbortsOnRestart(t *testing.T) {
svc := newAudioDownloadTestService(t)
const mediaURL = "https://cdn.example.com/long/restart.m4a"
payload := bytes.Repeat([]byte("zz"), 100)
// 先留一个分片,让第二轮带 Range。
partPath := svc.RemoteAudioCachePath("book-r", mediaURL) + audioPartialSuffix
if err := os.MkdirAll(filepath.Dir(partPath), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(partPath, payload[:50], 0o640); err != nil {
t.Fatal(err)
}
var sink bytes.Buffer
_, err := svc.DownloadRemoteAudioStreaming(t.Context(), "book-r", mediaURL, 1,
func(rangeHeader string) (*http.Response, []byte, error) {
// 无视 Range,整段回 200 → 直通无法重来。
return okResponse(payload), nil, nil
},
nil,
&sink,
)
if !errors.Is(err, errAudioStreamCannotRestart) {
t.Fatalf("应返回不可重来错误,实际 %v", err)
}
if sink.Len() != 0 {
t.Fatalf("中止前不应向客户端写出字节,实际 %d", sink.Len())
}
}
// writerFunc 把函数适配成 io.Writer。
type writerFunc func(p []byte) (int, error)
func (f writerFunc) Write(p []byte) (int, error) { return f(p) }
@@ -58,7 +58,7 @@ func TestAudioBookTypeFallbackBySource(t *testing.T) {
t.Fatalf("保存目录失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
@@ -125,7 +125,7 @@ func TestDeclaredTextTypeWinsOverAudioSource(t *testing.T) {
t.Fatalf("保存目录失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
+1 -1
View File
@@ -75,7 +75,7 @@ func TestGetContentForBookExtractsImageSrc(t *testing.T) {
}
_ = sourceID
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
+3 -3
View File
@@ -207,7 +207,7 @@ func TestGetContentForBookExtractsComments(t *testing.T) {
t.Fatalf("写入章节失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
@@ -282,7 +282,7 @@ func TestGetContentForBookKeepsTextWithStandaloneImage(t *testing.T) {
`<img src="https://cdn.example.com/pic.jpg">`
svc, bookID := setupTextChapterBook(t, content)
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0, ContentOptions{})
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
@@ -310,7 +310,7 @@ func TestGetContentForBookStripsBlockTagsKeepsText(t *testing.T) {
`<img src="https://cdn.example.com/pic.jpg">`
svc, bookID := setupTextChapterBook(t, content)
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0, ContentOptions{})
if err != nil {
t.Fatalf("取正文失败: %v", err)
}
@@ -0,0 +1,69 @@
package reader
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 上游限流时详情解析不到封面,规则会把页面地址当封面回退。这类记录被存进书架后
// 会一直显示破图;打开一次详情页应当用真实封面把它修好。
func TestRepairShelfCoverReplacesFallbackURL(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
const origin = "https://api.example.com/book"
const bookURL = "https://api.example.com/book?bookId=1"
const cover = "https://img.example.com/c.jpg"
book := &model.ReaderBook{
UserID: "u1",
Origin: origin,
BookURL: bookURL,
Name: "测试书",
CoverURL: bookURL, // 规则取空后回退的产物
}
if err := svc.repo.CreateBook(ctx, book); err != nil {
t.Fatal(err)
}
svc.RepairShelfCover(ctx, "u1", origin, bookURL, cover)
stored, err := svc.repo.GetBook(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if stored.CoverURL != cover {
t.Fatalf("封面应修为 %q,实际 %q", cover, stored.CoverURL)
}
}
// 已经有有效封面的书不能被动覆盖(可能是换源得到的、或用户自己挑的)。
func TestRepairShelfCoverKeepsValidCover(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
const origin = "https://api.example.com/book"
const bookURL = "https://api.example.com/book?bookId=1"
book := &model.ReaderBook{
UserID: "u1",
Origin: origin,
BookURL: bookURL,
Name: "测试书",
CoverURL: "https://img.example.com/old.jpg",
}
if err := svc.repo.CreateBook(ctx, book); err != nil {
t.Fatal(err)
}
svc.RepairShelfCover(ctx, "u1", origin, bookURL, "https://img.example.com/new.jpg")
stored, err := svc.repo.GetBook(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if stored.CoverURL != "https://img.example.com/old.jpg" {
t.Fatalf("有效封面不应被覆盖,实际 %q", stored.CoverURL)
}
}
+32 -16
View File
@@ -6,32 +6,48 @@ import "testing"
// normalizeCoverURL 必须拦住这类非图片地址,避免前端 <img> 显示破图。
func TestNormalizeCoverURL(t *testing.T) {
cases := []struct {
name string
in string
want string
name string
in string
baseURL string
want string
}{
{"empty", "", ""},
{"blank", " ", ""},
{"https", "https://img.example.com/a.jpg", "https://img.example.com/a.jpg"},
{"http", "http://img.example.com/a.jpg", "http://img.example.com/a.jpg"},
{"uppercase scheme", "HTTPS://img.example.com/a.jpg", "HTTPS://img.example.com/a.jpg"},
{"trimmed", " https://img.example.com/a.jpg ", "https://img.example.com/a.jpg"},
{"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo="},
{"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "/api/reader/local/asset?b=1&p=2&s=3"},
{"empty", "", "", ""},
{"blank", " ", "", ""},
{"https", "https://img.example.com/a.jpg", "", "https://img.example.com/a.jpg"},
{"http", "http://img.example.com/a.jpg", "", "http://img.example.com/a.jpg"},
{"uppercase scheme", "HTTPS://img.example.com/a.jpg", "", "HTTPS://img.example.com/a.jpg"},
{"trimmed", " https://img.example.com/a.jpg ", "", "https://img.example.com/a.jpg"},
{"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "", "data:image/png;base64,iVBORw0KGgo="},
{"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "", "/api/reader/local/asset?b=1&p=2&s=3"},
{
"search params envelope",
"data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ==",
"",
"",
},
{"text data uri", "data:text/plain;base64,aGk=", "", ""},
{"source name", "光遇聚合", "", ""},
{"javascript url", "javascript:alert(1)", "", ""},
{
// 详情规则取不到 $.meta.cover 时按 legado 语义回退成页面地址,
// 这个接口地址不是图片,必须丢掉。
"fallback to page url",
"https://api.example.com/book?bookId=1",
"https://api.example.com/book?bookId=1",
"",
},
{
"real cover with page url base",
"https://img.example.com/a.jpg",
"https://api.example.com/book?bookId=1",
"https://img.example.com/a.jpg",
},
{"text data uri", "data:text/plain;base64,aGk=", ""},
{"source name", "光遇聚合", ""},
{"javascript url", "javascript:alert(1)", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeCoverURL(tc.in); got != tc.want {
t.Fatalf("normalizeCoverURL(%q) = %q, want %q", tc.in, got, tc.want)
if got := normalizeCoverURL(tc.in, tc.baseURL); got != tc.want {
t.Fatalf("normalizeCoverURL(%q, %q) = %q, want %q", tc.in, tc.baseURL, got, tc.want)
}
})
}
+24 -3
View File
@@ -4,6 +4,7 @@
// 一样存 reader_chapters,用 ReaderChapter.Tag 记住定位信息:
// - TXT:UTF-8 规范化后文件内的字节区间 "start:end"(读章节只读这段,不载整本)
// - EPUB:zip 内的 XHTML 条目路径(每次按需解压该条)
//
// 网络书籍不受影响:ReaderBook.LocalPath 为空即走书源链路。
package reader
@@ -480,6 +481,13 @@ func (s *ReaderService) BackfillLocalCover(ctx context.Context, book *model.Read
// LocalChapterContent 读本地书籍的某一章正文(不做书源抓取)。
func (s *ReaderService) LocalChapterContent(
ctx context.Context, userID, bookID string, chapterIndex int,
) (*ChapterContent, error) {
return s.localChapterContentWithOptions(ctx, userID, bookID, chapterIndex, ContentOptions{})
}
// localChapterContentWithOptions 保留网页默认行为,原生客户端可选择自行解码音轨。
func (s *ReaderService) localChapterContentWithOptions(
ctx context.Context, userID, bookID string, chapterIndex int, opts ContentOptions,
) (*ChapterContent, error) {
book, err := s.repo.GetBook(ctx, bookID)
if err != nil {
@@ -501,12 +509,20 @@ func (s *ReaderService) LocalChapterContent(
ch := chapters[chapterIndex]
// 本地有声书:一章 = 一个音频文件或一个 .strm 播放指针
if book.Type == 1 {
track, transcoding, err := s.localAudioTrack(book, ch)
track, transcoding, err := s.localAudioTrackWithOptions(book, ch, opts)
if err != nil {
return nil, err
}
// 签名代理路径隐藏了 .m3u8 后缀,客户端必须收到 HLS 类型才能选对解复用器。
isHLS := looksLikeHLSURL(track)
if parsed, parseErr := url.Parse(track); parseErr == nil && parsed.Path == "/api/reader/media" {
q := parsed.Query()
if original, verifyErr := s.VerifyProxyURL(book.ID, q.Get("u"), q.Get("s")); verifyErr == nil {
isHLS = looksLikeHLSURL(original)
}
}
return &ChapterContent{
Type: "audio", Tracks: []string{track}, Transcoding: transcoding, declaredType: -1,
Type: "audio", Tracks: []string{track}, Transcoding: transcoding, IsHLS: isHLS, declaredType: -1,
}, nil
}
text, err := s.readLocalChapter(book, ch)
@@ -528,6 +544,11 @@ func (s *ReaderService) LocalChapterContent(
// .strm 指针解析成远端地址(走媒体代理)或本地文件(走本地音频端点);
// 浏览器解不了的格式(WMA 等)改走转码端点。第二个返回值表示该章需要转码。
func (s *ReaderService) localAudioTrack(book *model.ReaderBook, ch model.ReaderChapter) (string, bool, error) {
return s.localAudioTrackWithOptions(book, ch, ContentOptions{})
}
// localAudioTrackWithOptions 原生解码只跳过浏览器转码选择,仍使用相同的签名端点。
func (s *ReaderService) localAudioTrackWithOptions(book *model.ReaderBook, ch model.ReaderChapter, opts ContentOptions) (string, bool, error) {
target := strings.TrimSpace(ch.Tag)
if target == "" {
return "", false, fmt.Errorf("章节定位信息损坏,请重新导入该书")
@@ -546,7 +567,7 @@ func (s *ReaderService) localAudioTrack(book *model.ReaderBook, ch model.ReaderC
target = filepath.Join(filepath.Dir(ch.Tag), target)
}
}
if needsAudioTranscode(target) {
if !opts.NativeAudio && needsAudioTranscode(target) {
if s.ffmpegBinary() == "" {
return "", false, transcodeMissingFFmpegError(target)
}
@@ -0,0 +1,168 @@
package reader
import (
"strings"
"testing"
)
// 本文件:媒体直连(客户端自己取音频/漫画图片字节)的回归测试。
//
// 契约:
// - 默认(ContentOptions 零值)必须与历史行为完全一致:媒体地址一律改写成
// 服务端签名代理,响应里不出现直连标记与请求头 —— 网页端依赖这一点。
// - 客户端显式请求直连 + 书源开启开关时:保留上游原始地址,并下发取媒体
// 所需的请求头。
// - 客户端请求直连但书源没开开关时:退回代理,不能把未开启的源推到客户端直连。
// TestMediaDirectDefaultsToProxy 不传直连选项时行为不变(网页端契约)。
func TestMediaDirectDefaultsToProxy(t *testing.T) {
svc, _, book := prepareTicketBook(t)
ctx := t.Context()
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("读取失败: %v", err)
}
if len(out.Tracks) == 0 {
t.Fatal("应至少有一条音轨")
}
if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") {
t.Fatalf("默认应改写为服务端代理,实际 %q", out.Tracks[0])
}
if out.MediaDirect {
t.Fatal("默认不应标记为直连")
}
if len(out.MediaHeaders) != 0 {
t.Fatalf("代理模式不应下发取媒体请求头,实际 %v", out.MediaHeaders)
}
}
// TestMediaDirectRequiresSourceSwitch 书源没开开关时,即使客户端请求直连也只给代理。
func TestMediaDirectRequiresSourceSwitch(t *testing.T) {
svc, srv, book := prepareTicketBook(t)
ctx := t.Context()
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{MediaDirect: true})
if err != nil {
t.Fatalf("读取失败: %v", err)
}
if out.MediaDirect {
t.Fatal("书源未开启直连,不应标记为直连")
}
if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") {
t.Fatalf("书源未开启直连时仍应走代理,实际 %q", out.Tracks[0])
}
_ = srv
}
// TestMediaDirectKeepsUpstreamURL 开启开关后:地址是上游原始地址,并下发请求头。
func TestMediaDirectKeepsUpstreamURL(t *testing.T) {
svc, srv, book := prepareTicketBook(t)
ctx := t.Context()
if err := svc.SetSourceMediaDirect(ctx, readerTestUserID, sourceIDOf(t, svc, book.Origin), true); err != nil {
t.Fatalf("开启直连失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{MediaDirect: true})
if err != nil {
t.Fatalf("读取失败: %v", err)
}
if !out.MediaDirect {
t.Fatal("书源已开启直连,响应应标记 media_direct")
}
if len(out.Tracks) == 0 {
t.Fatal("应至少有一条音轨")
}
track := out.Tracks[0]
if strings.HasPrefix(track, "/api/") {
t.Fatalf("直连模式不该返回服务端代理地址,实际 %q", track)
}
if !strings.HasPrefix(track, srv.URL+"/media") {
t.Fatalf("直连模式应保留上游原始地址,实际 %q", track)
}
// 取媒体需要的防盗链头必须下发,否则客户端拿不到字节。
if out.MediaHeaders["Referer"] == "" {
t.Fatalf("直连模式应下发 Referer,实际 %v", out.MediaHeaders)
}
if out.MediaHeaders["User-Agent"] == "" {
t.Fatalf("直连模式应下发 User-Agent,实际 %v", out.MediaHeaders)
}
// 传输层头交给客户端 HTTP 栈,不能下发。
if _, ok := out.MediaHeaders["Accept-Encoding"]; ok {
t.Fatalf("不应下发 Accept-Encoding: %v", out.MediaHeaders)
}
}
// TestMediaDirectAppliesToCachedChapter 缓存命中的章节同样要按本次请求决定通道。
//
// 缓存里存的是书源侧原始地址(改写前),若命中后不改写就会把上游地址直接
// 漏给代理模式的客户端,表现为「图片/音频打不开」。
func TestMediaDirectAppliesToCachedChapter(t *testing.T) {
svc, _, book := prepareTicketBook(t)
ctx := t.Context()
// 首次读取写入缓存(直连模式)。
if err := svc.SetSourceMediaDirect(ctx, readerTestUserID, sourceIDOf(t, svc, book.Origin), true); err != nil {
t.Fatalf("开启直连失败: %v", err)
}
first, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{MediaDirect: true})
if err != nil || !first.MediaDirect {
t.Fatalf("首次直连读取失败: %v", err)
}
// 同章再按代理模式读:必须拿到签名代理地址,而不是缓存里的上游地址。
second, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("二次读取失败: %v", err)
}
if second.MediaDirect {
t.Fatal("代理模式不应标记直连")
}
if !strings.HasPrefix(second.Tracks[0], "/api/reader/media?") {
t.Fatalf("缓存命中后仍应按代理改写,实际 %q", second.Tracks[0])
}
}
// TestMediaDirectForceProxyWins 客户端显式要求代理时,即使书源开着直连也走代理。
//
// 这是直连取不到字节时的退路:不让用户为了继续读一章去改书源设置。
func TestMediaDirectForceProxyWins(t *testing.T) {
svc, _, book := prepareTicketBook(t)
ctx := t.Context()
if err := svc.SetSourceMediaDirect(ctx, readerTestUserID, sourceIDOf(t, svc, book.Origin), true); err != nil {
t.Fatalf("开启直连失败: %v", err)
}
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0,
ContentOptions{MediaDirect: true, ForceProxy: true})
if err != nil {
t.Fatalf("读取失败: %v", err)
}
if out.MediaDirect {
t.Fatal("强制代理时不应标记直连")
}
if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") {
t.Fatalf("强制代理时应返回签名代理地址,实际 %q", out.Tracks[0])
}
if len(out.MediaHeaders) != 0 {
t.Fatalf("代理模式不应下发请求头,实际 %v", out.MediaHeaders)
}
}
// sourceIDOf 按书源 URL 取当前用户的书源 ID。
func sourceIDOf(t *testing.T, svc *ReaderService, origin string) string {
t.Helper()
srcs, err := svc.ListSources(t.Context(), readerTestUserID)
if err != nil {
t.Fatalf("列书源失败: %v", err)
}
for _, src := range srcs {
if src.SourceURL == origin {
return src.ID
}
}
t.Fatalf("找不到书源 %s", origin)
return ""
}
+190
View File
@@ -0,0 +1,190 @@
package reader
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/truewhile/MeBox/internal/model"
)
// 本文件处理「音频直链的签名票过期」这一类上游故障。
//
// 背景:听书源(聆阅·懒人听书这类)的播放地址是第三方中转现签的 CDN 直链,
// 形如 https://vb.stream.tencentmusic.com/long/xxx.m4a?...&vkey=...&ttl=86400000。
// 票的有效性不只取决于时间:中转背后是多个账号 token 轮换,某个 uid/vkey 组合
// 会被 CDN 判掉,此时上游返回**空的 401**(无 Content-Type、无 body),而同一章
// 隔几百毫秒重新签一张票就又能取到。
//
// 旧行为:媒体代理把这个 401 原样透传给播放器,播放器重试的又是同一个 URL,
// 于是 App 日志里出现连续 4~8 个 401;同时音频清单还有 30 分钟缓存,这 30 分钟
// 内 /content 一直下发同一张死票,表现为「这本书怎么都播不了,换本书才行」。
//
// 现在的行为:代理遇到 401/403/410 时先把该章的缓存作废、重新走一次书源取正文,
// 拿到新票再拉一次;新票同时写回缓存,后续请求自然就是有效的。
// mediaRefreshTTL 新票的短缓存:播放器一次失败会立刻用同一 URL 重试多次,
// 这里让并发的重试共用一次回源,避免把书源打成限流。
const mediaRefreshTTL = 2 * time.Minute
// mediaRefreshEntry 一次成功换票的结果。
type mediaRefreshEntry struct {
url string
exp time.Time
}
// mediaRefreshState 保护「旧直链 → 新直链」的短缓存。
type mediaRefreshState struct {
mu sync.Mutex
entries map[string]mediaRefreshEntry
}
// lookupMediaRefresh 查换票短缓存(过期条目顺手清掉)。
func (s *ReaderService) lookupMediaRefresh(staleURL string) (string, bool) {
s.mediaRefresh.mu.Lock()
defer s.mediaRefresh.mu.Unlock()
entry, ok := s.mediaRefresh.entries[staleURL]
if !ok {
return "", false
}
if time.Now().After(entry.exp) {
delete(s.mediaRefresh.entries, staleURL)
return "", false
}
return entry.url, true
}
// rememberMediaRefresh 记住换票结果,并顺手清理过期条目(表很小,遍历即可)。
func (s *ReaderService) rememberMediaRefresh(staleURL, freshURL string) {
s.mediaRefresh.mu.Lock()
defer s.mediaRefresh.mu.Unlock()
if s.mediaRefresh.entries == nil {
s.mediaRefresh.entries = map[string]mediaRefreshEntry{}
}
now := time.Now()
for k, v := range s.mediaRefresh.entries {
if now.After(v.exp) {
delete(s.mediaRefresh.entries, k)
}
}
s.mediaRefresh.entries[staleURL] = mediaRefreshEntry{url: freshURL, exp: now.Add(mediaRefreshTTL)}
}
// RefreshMediaURL 在直链签名失效后重新换一张票。
//
// staleURL 是书源侧产出的原始媒体地址(媒体代理已还原、未加签名)。返回三个值:
//
// refreshable=false 这个地址不是「可换票的音轨」(漫画图片、封面、或并非来自
// 章节缓存的地址),调用方应原样透传上游状态,保持旧行为;
// refreshable=true 且 err!=nil 确实是音轨但换票失败,调用方应给出明确错误,
// 别让播放器对着必然失败的地址反复重试;
// refreshable=true 且 err==nil 拿到了新票。
//
// 换票流程:在书架书籍的章节缓存里找到它所属的那一章 → 作废该章缓存(否则
// /content 会继续下发同一张死票)→ 重新走书源取正文 → 按同一个音轨序号取新地址。
func (s *ReaderService) RefreshMediaURL(ctx context.Context, book *model.ReaderBook, staleURL string) (string, bool, error) {
if s == nil || s.repo == nil || book == nil {
return "", false, nil
}
staleURL = strings.TrimSpace(staleURL)
if staleURL == "" {
return "", false, nil
}
if fresh, ok := s.lookupMediaRefresh(staleURL); ok {
return fresh, true, nil
}
sourceID := s.contentSourceID(ctx, nil, book)
if sourceID == "" {
return "", false, nil
}
bookKey := contentBookKey(sourceID, book.BookURL)
rows, err := s.repo.ListContentCacheByBook(ctx, bookKey)
if err != nil || len(rows) == 0 {
return "", false, nil
}
for i := range rows {
row := rows[i]
payload, ok := s.readContentPayload(&row)
if !ok || payload.ContentType != "audio" {
continue
}
trackIndex := indexOfString(payload.Tracks, staleURL)
if trackIndex < 0 {
continue
}
ch, ok := s.chapterForCacheRow(ctx, book, row)
if !ok {
return "", true, fmt.Errorf("章节已不在目录中")
}
// 先作废:即使后面重抓失败,也不会继续把死票发给播放器。
s.dropContentCacheRow(ctx, &row)
out, fetchErr := s.fetchChapterContentShared(ctx, book, ch)
if fetchErr != nil {
return "", true, fmt.Errorf("重新取章节失败: %w", fetchErr)
}
if out == nil || trackIndex >= len(out.Tracks) {
return "", true, fmt.Errorf("新内容没有对应的音轨")
}
fresh := strings.TrimSpace(out.Tracks[trackIndex])
if strings.HasPrefix(fresh, "/api/") {
// fetchChapterContentShared 返回缓存原样地址;兜底解析一次代理形态。
return "", true, fmt.Errorf("书源返回了代理地址")
}
if fresh == "" || fresh == staleURL {
return "", true, fmt.Errorf("书源返回了相同地址")
}
s.rememberMediaRefresh(staleURL, fresh)
return fresh, true, nil
}
return "", false, nil
}
// readContentPayload 读取一条章节缓存的落盘载荷。
func (s *ReaderService) readContentPayload(row *model.ReaderContentCache) (cachedChapterContent, bool) {
var payload cachedChapterContent
path := s.contentPathFromRow(row)
if path == "" {
return payload, false
}
raw, err := os.ReadFile(path) // #nosec G304 -- 路径由服务端生成
if err != nil {
return payload, false
}
if err := json.Unmarshal(raw, &payload); err != nil {
return payload, false
}
return payload, true
}
// chapterForCacheRow 按缓存行的章节键找回目录里的章节(remap 后键仍一致)。
func (s *ReaderService) chapterForCacheRow(ctx context.Context, book *model.ReaderBook, row model.ReaderContentCache) (model.ReaderChapter, bool) {
chapters, err := s.repo.ListChapters(ctx, book.ID)
if err != nil {
return model.ReaderChapter{}, false
}
for _, ch := range chapters {
if contentChapterKey(book, ch) == row.ChapterKey {
return ch, true
}
}
// 退一步按序号取:目录里同一章可能因为标题变化换了身份键。
if row.ChapterIndex >= 0 && row.ChapterIndex < len(chapters) && !chapters[row.ChapterIndex].IsVolume {
return chapters[row.ChapterIndex], true
}
return model.ReaderChapter{}, false
}
// indexOfString 返回目标字符串在列表中的下标,找不到返回 -1。
func indexOfString(list []string, target string) int {
target = strings.TrimSpace(target)
for i, item := range list {
if strings.TrimSpace(item) == target {
return i
}
}
return -1
}
@@ -0,0 +1,263 @@
package reader
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 本文件:「音频直链签名票被 CDN 拒掉」后的换票回归测试。
//
// 背景:听书源(聆阅·懒人听书)的播放地址是第三方中转现签的 CDN 直链,票会被
// 上游判掉,此时 CDN 回的是**空的 401**(无 Content-Type、无 body)。旧行为把它
// 原样透传给播放器,播放器重试的又是同一个 URL,于是连续 4~8 个 401,
// App 日志表现为 ERROR_CODE_IO_BAD_HTTP_STATUS (2004)「怎么都播不了」。
//
// 现在的约定(RefreshMediaURL):
// - 能定位到归属章节 → 换票(先作废缓存,再重新走书源取正文);
// - 地址不是音轨(漫画图片、封面等)→ refreshable=false,调用方原样透传;
// - 是音轨但换不到 → refreshable=true + err,调用方给出明确错误不再让播放器重试。
// ticketServer 模拟「中转现签 CDN 直链」的上游:
// - /book/1 详情页(含目录链接)
// - /toc 目录
// - /content 章节正文,每次请求签一张新票
// - /media 媒体本体,只认最新签出的那张票,其余回空的 401
type ticketServer struct {
*httptest.Server
mu sync.Mutex
issued []string
hits int
noToken bool // true 时正文里不带票(模拟书源不再给新地址)
}
func newTicketServer() *ticketServer {
s := &ticketServer{}
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/book/1"):
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body><h1>票源之书</h1></body></html>`))
case r.URL.Path == "/content":
s.mu.Lock()
s.hits++
n := s.hits
noToken := s.noToken
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if noToken {
_, _ = w.Write([]byte(`{"data":{"url":""}}`))
return
}
token := fmt.Sprintf("ticket-%d", n)
s.mu.Lock()
s.issued = append(s.issued, token)
s.mu.Unlock()
_, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media?t=%s"}}`, s.URL, token)))
case r.URL.Path == "/media":
// 只认最近签出的那张票;旧票回空的 401(与腾讯 CDN 行为一致)。
token := r.URL.Query().Get("t")
s.mu.Lock()
valid := len(s.issued) > 0 && s.issued[len(s.issued)-1] == token
s.mu.Unlock()
if !valid {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "audio/mp4")
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42fake-audio-bytes"))
default:
http.NotFound(w, r)
}
}))
return s
}
func (s *ticketServer) contentHits() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.hits
}
// ticketSourceJSON 一个最小听书源:正文规则取 JSON 里的播放直链。
func ticketSourceJSON(t *testing.T, base string) string {
t.Helper()
return `{
"bookSourceName": "票源测试",
"bookSourceType": 1,
"bookSourceUrl": "` + base + `",
"ruleContent": {"content": "$.data.url"}
}`
}
// prepareTicketBook 建好「听书源 + 书架书籍 + 一章目录」,并开独立缓存目录。
func prepareTicketBook(t *testing.T) (*ReaderService, *ticketServer, *model.ReaderBook) {
t.Helper()
srv := newTicketServer()
t.Cleanup(srv.Close)
svc, _ := newLoginTestService(t)
svc.cfg.Cache.CacheDir = t.TempDir()
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, ticketSourceJSON(t, srv.URL))
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
SourceID: sourceID,
Origin: srv.URL,
OriginName: "票源测试",
OriginType: 1,
BookURL: srv.URL + "/book/1",
}, "票源之书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
}); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
return svc, srv, book
}
// TestRefreshMediaURLIssuesNewTicket 票被拒后换票:拿到新地址、缓存被刷新。
func TestRefreshMediaURLIssuesNewTicket(t *testing.T) {
svc, srv, book := prepareTicketBook(t)
ctx := t.Context()
// 首读:拿到第一张票并落缓存
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("首次读取失败: %v", err)
}
if len(out.Tracks) != 1 {
t.Fatalf("音轨数 = %d", len(out.Tracks))
}
stale, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
if err != nil {
t.Fatalf("还原直链失败: %v", err)
}
fresh, refreshable, err := svc.RefreshMediaURL(ctx, book, stale)
if err != nil {
t.Fatalf("换票失败: %v", err)
}
if !refreshable {
t.Fatal("音轨地址应当可换票")
}
if fresh == stale {
t.Fatalf("换票后地址没变: %q", fresh)
}
if got := srv.contentHits(); got != 2 {
t.Fatalf("换票应回源重取一次章节,实际请求 %d 次", got)
}
// 新票必须能取到媒体本体(旧票是空的 401)
resp, err := http.Get(fresh)
if err != nil {
t.Fatalf("新票请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("新票状态码 = %d,期望 200", resp.StatusCode)
}
// 缓存要被刷新成新票:再次读章节不应再回源
out2, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("换票后读章节失败: %v", err)
}
refreshed, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out2.Tracks[0], "u"), trackQueryValue(t, out2.Tracks[0], "s"))
if err != nil {
t.Fatalf("还原刷新后的直链失败: %v", err)
}
if refreshed != fresh {
t.Fatalf("章节缓存应刷新为新票 %q,实际 %q", fresh, refreshed)
}
if got := srv.contentHits(); got != 2 {
t.Fatalf("缓存已刷新,不应再回源,实际请求 %d 次", got)
}
}
// TestRefreshMediaURLMemoryReusesTicket 同一张旧票的多次换票只回源一次:
// 播放器一次失败会立刻用同一 URL 重试多次,重复回源会把书源打成限流。
func TestRefreshMediaURLMemoryReusesTicket(t *testing.T) {
svc, srv, book := prepareTicketBook(t)
ctx := t.Context()
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("首次读取失败: %v", err)
}
stale, _ := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
first, _, err := svc.RefreshMediaURL(ctx, book, stale)
if err != nil {
t.Fatalf("首次换票失败: %v", err)
}
for i := 0; i < 3; i++ {
again, refreshable, err := svc.RefreshMediaURL(ctx, book, stale)
if err != nil || !refreshable {
t.Fatalf("第 %d 次换票失败: refreshable=%v err=%v", i+2, refreshable, err)
}
if again != first {
t.Fatalf("同一旧票应复用同一个新票:%q vs %q", again, first)
}
}
if got := srv.contentHits(); got != 2 {
t.Fatalf("重复换票只该回源一次,实际 %d 次", got)
}
}
// TestRefreshMediaURLSkipsNonTrackURL 漫画图片/防盗链图片这类地址不属于音轨,
// 调用方应原样透传上游状态(不能被换票逻辑吃掉响应体)。
func TestRefreshMediaURLSkipsNonTrackURL(t *testing.T) {
svc, _, book := prepareTicketBook(t)
ctx := t.Context()
if _, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{}); err != nil {
t.Fatalf("首次读取失败: %v", err)
}
_, refreshable, err := svc.RefreshMediaURL(ctx, book, "https://img.example.com/cover.jpg")
if err != nil {
t.Fatalf("非音轨地址不该报错: %v", err)
}
if refreshable {
t.Fatal("非音轨地址不该被判为可换票")
}
}
// TestRefreshMediaURLErrorsWhenSourceRepeatsTicket 书源重新签出同一张票时,
// 说明中转本身没换票,应当明确失败而不是让播放器继续对着死票重试。
func TestRefreshMediaURLErrorsWhenSourceRepeatsTicket(t *testing.T) {
svc, srv, book := prepareTicketBook(t)
ctx := t.Context()
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("首次读取失败: %v", err)
}
stale, _ := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
// 让书源此后只回空地址:换票必然失败。
srv.mu.Lock()
srv.noToken = true
srv.mu.Unlock()
_, refreshable, err := svc.RefreshMediaURL(ctx, book, stale)
if !refreshable {
t.Fatal("音轨地址应当被判为可换票")
}
if err == nil {
t.Fatal("书源给不出新票时应当报错")
}
// 作废过的缓存不该把死票留在库里
if _, ok := svc.loadCachedContent(ctx, nil, nil, book, model.ReaderChapter{
Index: 0, Title: "第一章", URL: srv.URL + "/content",
}, "audio"); ok {
t.Fatal("换票失败后旧缓存应已被作废,不应继续下发死票")
}
}
@@ -0,0 +1,127 @@
package reader
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// 原生解码与媒体通道独立:本地文件和 STRM 都保留原始目标及签名,不依赖 ffmpeg。
func TestNativeLocalAudioContent(t *testing.T) {
for _, tc := range []struct {
name string
target string // 非空时写入 STRM;空则直接导入 WMA
}{
{name: "local WMA"},
{name: "relative STRM WMA", target: "../original.wma"},
{name: "remote STRM WMA", target: "https://cdn.example.com/01.wma?token=original"},
{name: "STRM playback API WMA", target: "http://127.0.0.1:8080/api/strm/play/cloud115/video.wma?acct=a&pickcode=p"},
{name: "remote STRM HLS", target: "https://cdn.example.com/01.m3u8?token=original"},
} {
t.Run(tc.name, func(t *testing.T) {
svc := newLocalBookService(t)
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "missing-ffmpeg")
dir := t.TempDir()
original := filepath.Join(dir, "01.wma")
body := "original-wma-bytes"
if tc.target != "" {
original = tc.target
body = tc.target + "\n"
}
name := "01.wma"
if tc.target != "" {
name = "01.strm"
}
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o640); err != nil {
t.Fatal(err)
}
if tc.target != "" && !isRemoteMediaURL(original) {
original = filepath.Clean(filepath.Join(dir, original))
}
book, err := svc.ImportLocalAudioDir(t.Context(), "u1", dir)
if err != nil {
t.Fatal(err)
}
for _, opts := range []ContentOptions{
{NativeAudio: true},
{NativeAudio: true, MediaDirect: true},
{NativeAudio: true, ForceProxy: true},
} {
out, err := svc.GetContentForBook(t.Context(), "u1", book.ID, 0, opts)
if err != nil {
t.Fatalf("native opts=%+v: %v", opts, err)
}
if out.Type != "audio" || out.Transcoding || out.MediaDirect || len(out.Tracks) != 1 {
t.Fatalf("native content = %+v", out)
}
if out.IsHLS != looksLikeHLSURL(original) {
t.Fatalf("HLS flag=%v for %q", out.IsHLS, original)
}
track := out.Tracks[0]
var got string
if isRemoteMediaURL(original) {
if !strings.HasPrefix(track, "/api/reader/media?") {
t.Fatalf("remote target must use signed proxy: %q", track)
}
got, err = svc.VerifyProxyURL(book.ID, queryParam(t, track, "u"), queryParam(t, track, "s"))
if _, badErr := svc.VerifyProxyURL(book.ID, queryParam(t, track, "u"), "bad"); badErr == nil {
t.Fatal("tampered proxy signature accepted")
}
} else {
if !strings.HasPrefix(track, "/api/reader/local/audio?") {
t.Fatalf("local target must use signed audio endpoint: %q", track)
}
got, err = svc.VerifyLocalAudioURL(book.ID, queryParam(t, track, "p"), queryParam(t, track, "s"))
if _, badErr := svc.VerifyLocalAudioURL(book.ID, queryParam(t, track, "p"), "bad"); badErr == nil {
t.Fatal("tampered local signature accepted")
}
}
if err != nil || got != original {
t.Fatalf("original target = %q, err=%v, want %q", got, err, original)
}
batch, err := svc.GetContentBatch(t.Context(), "u1", book.ID, []int{0}, false, opts)
if err != nil {
t.Fatal(err)
}
if len(batch.Items) != 1 || batch.Items[0].Error != "" || batch.Items[0].Transcoding ||
len(batch.Items[0].Tracks) != 1 || batch.Items[0].Tracks[0] != track || batch.Items[0].IsHLS != out.IsHLS {
t.Fatalf("native batch = %+v", batch)
}
}
if _, err := svc.GetContentForBook(t.Context(), "other", book.ID, 0, ContentOptions{NativeAudio: true}); err == nil {
t.Fatal("native audio must retain book ownership check")
}
if !needsAudioTranscode(original) {
return
}
// 网页默认及旧方法仍要求 ffmpeg。
if _, err := svc.GetContentForBook(t.Context(), "u1", book.ID, 0, ContentOptions{}); !errors.Is(err, ErrAudioTranscodeUnavailable) {
t.Fatalf("browser without ffmpeg: %v", err)
}
if _, err := svc.LocalChapterContent(t.Context(), "u1", book.ID, 0); !errors.Is(err, ErrAudioTranscodeUnavailable) {
t.Fatalf("legacy local content without ffmpeg: %v", err)
}
// 这里只选择转码地址,不执行 ffmpeg;用已存在的可执行文件满足 LookPath。
svc.cfg.App.FFmpegPath, err = os.Executable()
if err != nil {
t.Fatal(err)
}
out, err := svc.GetContentForBook(t.Context(), "u1", book.ID, 0, ContentOptions{})
if err != nil {
t.Fatal(err)
}
if !out.Transcoding || len(out.Tracks) != 1 || out.Tracks[0] != svc.AudioTranscodeURL(book.ID, original) {
t.Fatalf("browser must still select transcode: %+v", out)
}
batch, err := svc.GetContentBatch(t.Context(), "u1", book.ID, []int{0}, false, ContentOptions{})
if err != nil {
t.Fatal(err)
}
if len(batch.Items) != 1 || batch.Items[0].Error != "" || !batch.Items[0].Transcoding {
t.Fatalf("browser batch must report transcoding: %+v", batch)
}
})
}
}
+70
View File
@@ -5,11 +5,13 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
)
@@ -132,3 +134,71 @@ func TestFetchMediaDoesNotRetryNotFound(t *testing.T) {
t.Fatalf("上游请求次数 = %d,期望 1(404 不该重试)", got)
}
}
// TestMediaHTTPClientHasNoOverallTimeout 媒体客户端不能用「整体超时」。
//
// 这是本次故障的第二个根因:http.Client.Timeout 覆盖「读完 body」这一阶段。
// 一个 4MB 的 m4a 在跨境链路上很容易超过 30 秒,超时后 net/http 直接掐断
// 响应体,而调用方的 io.Copy 只是正常返回——表现为**静默截断**(播放器拿到
// 半首歌,判「格式不支持」)或 502。所以媒体客户端必须没有整体超时。
func TestMediaHTTPClientHasNoOverallTimeout(t *testing.T) {
client := helper.NewMediaHTTPClient(false)
if client.Timeout != 0 {
t.Fatalf("媒体 client 的整体 Timeout = %v,必须为 0(大文件下载会被静默截断)", client.Timeout)
}
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("Transport 类型 = %T", client.Transport)
}
if tr.ResponseHeaderTimeout <= 0 {
t.Fatal("媒体 client 应设置 ResponseHeaderTimeout,避免上游不给首字节时无限挂起")
}
// 必须走 HTTP/1.1:腾讯 CDN 的 h2 边缘在长连接大文件上不回 WINDOW_UPDATE,
// Go 的 h2 初始连接窗口是 1MiB,下载会稳定断在 1MiB/2MiB/3MiB 整数倍处。
if tr.ForceAttemptHTTP2 {
t.Error("媒体 client 必须关闭 HTTP/2(1MiB 窗口截断)")
}
if tr.TLSNextProto == nil || len(tr.TLSNextProto) != 0 {
t.Error("媒体 client 需要清空 TLSNextProto,才能确保不用 h2")
}
}
// TestFetchMediaStreamsLargeBodyWithoutTruncation 大响应体必须完整送达。
//
// 用「慢速分块发送」模拟跨境链路上的大音频:总耗时超过规则请求的 30 秒超时,
// 但媒体通路必须把每一个字节都交出来,绝不静默截断。
func TestFetchMediaStreamsLargeBodyWithoutTruncation(t *testing.T) {
const totalBytes = 24 << 20 // 24MB
const chunk = 512 << 10
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(totalBytes))
payload := make([]byte, chunk)
for off := 0; off < totalBytes; off += chunk {
if _, err := w.Write(payload); err != nil {
return
}
w.(http.Flusher).Flush()
time.Sleep(5 * time.Millisecond)
}
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
book := &model.ReaderBook{Origin: srv.URL}
resp, err := svc.FetchMedia(t.Context(), book, srv.URL+"/big.m4a", "")
if err != nil {
t.Fatalf("FetchMedia 失败: %v", err)
}
defer resp.Body.Close()
got, readErr := io.Copy(io.Discard, resp.Body)
if readErr != nil {
t.Fatalf("读取响应体失败: %v", readErr)
}
if got != totalBytes {
t.Fatalf("交付字节 = %d,期望 %d(少了 %d 字节 → 播放器会判格式不支持)",
got, totalBytes, int64(totalBytes)-got)
}
}
+47
View File
@@ -80,12 +80,19 @@ func parseConcurrentRate(rate string) time.Duration {
const (
// maxRequestAttempts 单次请求最大尝试次数(含首次),兜住书源里写很大的 retry。
maxRequestAttempts = 5
// implicitRetryAttempts 书源没声明 retry 时,GET 请求在可重试状态码上的默认尝试次数。
// 实测这类聚合源的单次 429 概率接近一半,两次尝试仍有约两成失败,三次降到一成左右;
// 再往上加只会让被限流的接口更难恢复。退避总共不到 1 秒,详情/目录请求扛得住。
implicitRetryAttempts = 3
// requestRetryDelay 请求重试退避基数,实际等待为 attempt 倍。
requestRetryDelay = 300 * time.Millisecond
)
// retryableStatus 该状态码是否值得重试:限流(403/429)与上游抖动(5xx)可重试,
// 404/400 这类确定性错误不重试。规则请求与媒体代理共用。
//
// 注意 401 不在这里:书源的规则请求返回 401 通常是「登录态失效」,反复重试
// 只会白等退避时间。媒体直链另有语义,见 mediaRetryableStatus。
func retryableStatus(code int) bool {
switch code {
case 403, 408, 425, 429, 500, 502, 503, 504:
@@ -95,6 +102,46 @@ func retryableStatus(code int) bool {
}
}
// implicitRetryableStatus 书源没声明 retry 时,值得自动补一次的失败。
//
// 只覆盖「上游临时顶不住」的情形:429 是限流/并发上限,502/503/504 是网关或
// 实例暂时不可用,歇一轮基本会恢复。403(风控)重试同一份请求多半还是 403,
// 500 可能是源站自身 bug(确定性失败),都不在这里浪费一次往返。
func implicitRetryableStatus(code int) bool {
switch code {
case 429, 502, 503, 504:
return true
default:
return false
}
}
// mediaRetryableStatus 媒体拉取专用的可重试判定。
//
// 与规则请求不同,CDN 直链的 410 表示「对象已下线」,与 403/429 一样属于
// 上游抖动,同一张票稍后重试可能就恢复;而 401 是「签名票被拒」,重试同一张票
// 一定还是 401,必须由调用方回源换票(见 MediaNeedsRefresh)。
// 所以这里只补 410,不把 401 加进来。
func mediaRetryableStatus(code int) bool {
if retryableStatus(code) {
return true
}
return code == 410
}
// MediaNeedsRefresh 判断媒体上游状态是否值得「换一张签名票再试」。
//
// 401 是 CDN 对无效 vkey/uid 的固定回答(空 body、无 Content-Type),410 是
// 对象已下线,403 常见于票与资源不匹配。这三类都值得回到书源重新签票。
func MediaNeedsRefresh(code int) bool {
switch code {
case 401, 403, 410:
return true
default:
return false
}
}
// sleepWithContext 可被 ctx 取消的等待;返回 false 表示上下文已结束。
func sleepWithContext(ctx context.Context, d time.Duration) bool {
if d <= 0 {
@@ -71,3 +71,36 @@ func TestRetryableStatus(t *testing.T) {
}
}
}
// TestMediaRetryableStatus 媒体直链的 410(对象已下线)与 403/429 一样值得原地重试,
// 但 401「签名票被拒」原地重试一定是同样的结果,只能靠回源换票。
func TestMediaRetryableStatus(t *testing.T) {
for _, code := range []int{403, 410, 429, 500, 503} {
if !mediaRetryableStatus(code) {
t.Errorf("媒体状态码 %d 应当可重试", code)
}
}
for _, code := range []int{200, 206, 301, 400, 401, 404} {
if mediaRetryableStatus(code) {
t.Errorf("媒体状态码 %d 不该原地重试", code)
}
}
// 规则请求的判定不能被放宽:登录态失效的 401 反复重试只会白等。
if retryableStatus(401) || retryableStatus(410) {
t.Error("retryableStatus 不应包含 401/410")
}
}
// TestMediaNeedsRefresh 只有「票被拒」这一类状态才值得回源换票。
func TestMediaNeedsRefresh(t *testing.T) {
for _, code := range []int{401, 403, 410} {
if !MediaNeedsRefresh(code) {
t.Errorf("状态码 %d 应当换票重试", code)
}
}
for _, code := range []int{200, 206, 304, 400, 404, 429, 500} {
if MediaNeedsRefresh(code) {
t.Errorf("状态码 %d 不该换票", code)
}
}
}
+337 -85
View File
@@ -43,7 +43,14 @@ type ReaderService struct {
log *zap.Logger
repo *repository.ReaderRepository
http *http.Client
crypto *helper.SecretCipher
// mediaHTTP 用于转发媒体流(音频/视频/漫画大图)。
//
// 必须与 http 分开:http 设了 30 秒的「整体」超时(含读完 body),而一个
// 4MB 的音频在跨境链路上很容易超过它,超时后 net/http 直接掐断 body,
// 调用方的 io.Copy 却只是正常返回 —— 表现为静默截断(播放器拿到半首歌)
// 或 502。见 helper.NewMediaHTTPClient 的说明。
mediaHTTP *http.Client
crypto *helper.SecretCipher
// browserMu / browserPending 保护「待用户完成的页面」表
// (java.startBrowser / startBrowserAwait,见 browser_panel.go)。
@@ -71,6 +78,10 @@ type ReaderService struct {
// 阅读页与批量预取会并发请求同一章(见 fetchChapterContentShared)。
contentFlightsMu sync.Mutex
contentFlights map[string]*contentFlight
// mediaRefresh 记录「已失效直链 → 重新签到的新直链」的短缓存:
// CDN 判掉某张签名票后,播放器的多次重试共用一次回源(见 media_refresh.go)。
mediaRefresh mediaRefreshState
}
// NewReaderService 创建服务。
@@ -83,12 +94,21 @@ func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Con
log: log,
repo: repos.Reader,
http: client,
mediaHTTP: helper.NewMediaHTTPClient(true),
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
limiter: newSourceRateLimiter(),
coverDecodeOrigins: map[string]bool{},
}
}
// mediaClient 返回转发媒体流用的客户端(未注入时退回规则请求的 client)。
func (s *ReaderService) mediaClient() *http.Client {
if s != nil && s.mediaHTTP != nil {
return s.mediaHTTP
}
return s.http
}
// markSourceCoverDecode 记录某书源是否声明了 coverDecodeJs(导入/更新书源时调用)。
func (s *ReaderService) markSourceCoverDecode(sourceURL string, enabled bool) {
if s == nil || strings.TrimSpace(sourceURL) == "" {
@@ -405,12 +425,25 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
// retry:URL 选项声明的重试次数(对应 legado AnalyzeUrl 的同名字段)。
// 只重试可恢复的失败:网络错误、5xx、403/429(防盗链/限流)。确定性 4xx 不重试。
attempts := 1
if req.Retry != nil && *req.Retry > 0 {
declaredRetry := req.Retry != nil && *req.Retry > 0
if declaredRetry {
attempts += *req.Retry
if attempts > maxRequestAttempts {
attempts = maxRequestAttempts
}
}
// 书源没声明 retry 时,GET 也对上游瞬时失败(429/5xx)自动补一次。
// 很多聚合源跑在免费/低配后端上,限流是常态(懒人听书源实测约四成请求返回
// HTTP 429),而 429 的响应体根本不是书源数据,交给规则只会解析出空目录、
// 空详情。POST 可能有副作用,仍然只按书源声明重试。
if !declaredRetry && (req.Method == "" || req.Method == http.MethodGet) {
attempts = implicitRetryAttempts
}
// 自动补的一次只认「上游临时顶不住」的状态码,见 implicitRetryableStatus。
retryAllowed := retryableStatus
if !declaredRetry {
retryAllowed = implicitRetryableStatus
}
var (
resp *http.Response
data []byte
@@ -444,7 +477,7 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
}
return "", "", resp.StatusCode, err
}
if attempt < attempts && retryableStatus(resp.StatusCode) {
if attempt < attempts && retryAllowed(resp.StatusCode) {
if !sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) {
return "", "", resp.StatusCode, nil
}
@@ -516,6 +549,25 @@ func charsetFromContentType(ct string) string {
return strings.Trim(cs, "\"")
}
// upstreamStatusError 把书源接口的失败状态码翻译成可读错误。
//
// 上游限流/过载时返回的响应体不是书源数据:交给规则解析只会得到空目录、空详情,
// 页面就表现成「目录 0 章」而没有任何报错。这里让调用方拿到明确错误,
// 前端才能提示重试,而不是把「抓不到」当成「这本书没有章节」。
func upstreamStatusError(code int) error {
if code < 400 {
return nil
}
switch code {
case http.StatusTooManyRequests:
return fmt.Errorf("书源接口限流(HTTP 429),请稍后重试")
case http.StatusForbidden:
return fmt.Errorf("书源接口拒绝访问(HTTP 403),可能触发了风控")
default:
return fmt.Errorf("书源接口返回 HTTP %d", code)
}
}
// ─── 规则执行辅助 ───────────────────────────────────────────────────────────
// sourceSession 是一次书源操作的执行上下文:把书源记录、解析结构与
@@ -1111,10 +1163,13 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
return nil, req.Unsupported
}
sess.applyHeaders(req, runner)
body, finalURL, _, err := sess.fetch(req)
body, finalURL, code, err := sess.fetch(req)
if err != nil {
return nil, err
}
if err := upstreamStatusError(code); err != nil {
return nil, err
}
ar := sess.newAnalyzer(key, page, body, finalURL)
elements, err := ar.GetElements(SPtr(sr.BookList))
@@ -1144,7 +1199,7 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
WordCount: wordCount,
LatestChapter: lastChapter,
Intro: intro,
CoverURL: normalizeCoverURL(cover),
CoverURL: normalizeCoverURL(cover, finalURL),
BookURL: bookURL,
Origins: []SearchOrigin{{
SourceID: src.ID,
@@ -1201,11 +1256,20 @@ func firstNonEmpty(vals ...string) string {
// 于是封面上会落一串 data: 文本,<img> 按 text/plain 处理必然破图。
// 允许的相对地址以 "/" 开头,用于应用自身生成的本地书封面
// (/api/reader/local/asset?...)。
func normalizeCoverURL(raw string) string {
//
// baseURL 是规则解析时的页面地址(详情页就是书本地址)。取值为空时规则会原样
// 回退成它,回退结果同样不是图片:比如懒人听书源被上游限流(HTTP 429)时,
// 详情 JSON 里没有 meta.cover,封面就会变成 .../lrts-book?bookId=xxx 这个接口地址。
// 这类值必须一并丢掉,否则会被存进书架,之后一直显示破图。
func normalizeCoverURL(raw, baseURL string) string {
u := strings.TrimSpace(raw)
if u == "" {
return ""
}
// 规则取空后回退成页面地址的产物:不是图片,也不是用户要的封面。
if base := strings.TrimSpace(baseURL); base != "" && u == base {
return ""
}
lower := strings.ToLower(u)
switch {
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
@@ -1262,10 +1326,13 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
return nil, req.Unsupported
}
sess.applyHeaders(req, runner)
body, finalURL, _, err := sess.fetch(req)
body, finalURL, code, err := sess.fetch(req)
if err != nil {
return nil, err
}
if err := upstreamStatusError(code); err != nil {
return nil, err
}
ar := sess.newAnalyzer("", 0, body, finalURL)
sess.applyBookContext(ar, bookURL, book, "", 0)
@@ -1299,7 +1366,7 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
info.Intro = v
}
if v, err := ar.GetString(SPtr(bir.CoverURL), nil, true); err == nil && v != "" {
info.CoverURL = normalizeCoverURL(v)
info.CoverURL = normalizeCoverURL(v, finalURL)
}
if v, err := ar.GetString(SPtr(bir.TocURL), nil, true); err == nil && v != "" {
info.TocURL = v
@@ -1549,6 +1616,37 @@ func (s *ReaderService) persistBookTocURL(ctx context.Context, userID, origin, b
}
}
// RepairShelfCover 用详情页刚解析出的封面修复书架记录。
//
// 只修「明显坏掉」的旧记录:封面为空,或等于书本地址/书源地址(规则取空后回退
// baseUrl 的产物)。上游限流(HTTP 429)时详情里没有封面,旧版本会把接口地址
// 存进书架,之后一直显示破图;打开一次详情页就能自愈。已有有效封面的记录不动,
// 避免覆盖换源或用户数据。
func (s *ReaderService) RepairShelfCover(ctx context.Context, userID, origin, bookURL, cover string) {
if s.repo == nil || userID == "" || origin == "" || bookURL == "" {
return
}
// 书架记录里的 BookURL 与调用方传入的一致(可能带 ",{...}" 选项段),
// 查找用原值;只在校验封面是否为「回退地址」时用去掉选项段的形态。
pageURL := tocBookTarget(bookURL)
cover = normalizeCoverURL(cover, pageURL)
if cover == "" {
return
}
book, err := s.repo.FindBookByURL(ctx, userID, origin, bookURL)
if err != nil || book == nil {
return
}
current := strings.TrimSpace(book.CoverURL)
if current != "" && current != pageURL && current != strings.TrimSpace(origin) {
return
}
book.CoverURL = cover
if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil {
s.log.Warn("reader: 修复书架封面失败", zap.String("book", book.ID), zap.Error(err))
}
}
// applyTocMeta 把目录阶段得到的信息写回书架记录:
// - 书源在规则 JS 里声明的书籍类型(declared >= 0 时);
// - 末章标题,以及末章变化时刷新的「最近更新」时间(对应 legado Book.latestChapterTime)。
@@ -1675,10 +1773,13 @@ func (s *ReaderService) fetchTocPage(ctx context.Context, sess *sourceSession, b
return nil, nil, -1, req.Unsupported
}
sess.applyHeaders(req, runner)
body, finalURL, _, err := sess.fetch(req)
body, finalURL, code, err := sess.fetch(req)
if err != nil {
return nil, nil, -1, err
}
if err := upstreamStatusError(code); err != nil {
return nil, nil, -1, err
}
ar := sess.newAnalyzer("", 0, body, finalURL)
sess.applyBookContext(ar, bookURL, nil, "", 0)
@@ -1749,6 +1850,13 @@ type ChapterContent struct {
// 前端据此决定走 hls.js 而不是原生 <audio>:代理地址是 base64 编码的
// /api/reader/media?...&u=...,前端无法再从 URL 后缀判断格式。
IsHLS bool `json:"hls,omitempty"`
// MediaDirect 为真表示 Tracks/Images 是上游原始地址,由客户端直接去取,
// 服务端不再代理字节。此时 MediaHeaders 给出取这些地址所需的请求头。
// 只有客户端显式请求直连(ContentOptions.MediaDirect)且书源开启该开关时才会出现。
MediaDirect bool `json:"media_direct,omitempty"`
// MediaHeaders 直连模式下取媒体所需的请求头(防盗链 Referer/UA、登录 Cookie 等)。
// 代理模式下为空:请求头只在服务端内部使用,不下发给客户端。
MediaHeaders map[string]string `json:"media_headers,omitempty"`
// declaredType 书源在规则 JS 里声明的书籍类型(-1 表示未声明),
// 由 GetContentForBook 写回书架记录(对应 legado Book.type)。
declaredType int
@@ -1756,6 +1864,109 @@ type ChapterContent struct {
// ─── 媒体代理(音频流 / 漫画图片,带防盗链头与 HMAC 签名) ──────────────────
// ContentOptions 正文下发的可选行为,只由请求参数决定,不改变默认链路。
//
// 默认零值 = 今天的网页行为:媒体地址全部改写为服务端签名代理,由服务端
// 拉取字节再转发。MediaDirect 让客户端(Android App)自己去取上游字节,
// 服务端只负责解析地址与下发请求头。
type ContentOptions struct {
// NativeAudio 由客户端原生解码音频,本地有声书返回原始音轨,不检查 ffmpeg。
// 与 MediaDirect / ForceProxy 独立:只选择解码方式,不改变媒体签名与访问权限。
NativeAudio bool
// MediaDirect 请求直连媒体字节。仅对开启了 MediaDirect 的书源生效,
// 否则仍返回代理地址,避免把未验证的源推到客户端直连。
MediaDirect bool
// ForceProxy 本次强制走服务端代理,即使书源开着直连。
//
// 给客户端留的退路:直连取不到(防盗链、票失效、上游限流等)时可以要求
// 服务端先代理一次,而不是让用户去改书源设置。
ForceProxy bool
}
// mediaDirectFor 结合书源开关与本次请求选项,决定这一章是否直连。
func (s *ReaderService) mediaDirectFor(ctx context.Context, origin string, opts ContentOptions) bool {
if !opts.MediaDirect || opts.ForceProxy {
return false
}
return s.sourceAllowsMediaDirect(ctx, origin)
}
// applyMediaURLs 决定音频/图片地址是代理还是直连,并填好直连所需请求头。
//
// 直连时保留上游原始地址,同时把这一章的请求头一次性下发:同一章内的音轨与
// 图片通常共用书源级 header / 登录态,逐条下发会让响应体成倍变大。
// 逐图选项段(",{headers:...}")的差异不在这里展开 —— 客户端会自行拆分。
func (s *ReaderService) applyMediaURLs(ctx context.Context, book *model.ReaderBook, out *ChapterContent, direct bool) {
if out == nil {
return
}
if !direct {
for i, t := range out.Tracks {
out.Tracks[i] = s.ProxyURL(book.ID, t)
}
for i, img := range out.Images {
out.Images[i] = s.ProxyURL(book.ID, img)
}
return
}
out.MediaDirect = true
if out.MediaHeaders == nil {
out.MediaHeaders = map[string]string{}
}
// 请求头按章节内第一条媒体地址计算:同一章的音轨(或图片)通常同域名、
// 共用书源级 header 与登录态。逐图选项段(",{headers:...}")的差异留给
// 客户端自行拆分,不下发也不参与这里的计算。
sample := ""
if len(out.Tracks) > 0 {
sample = out.Tracks[0]
} else if len(out.Images) > 0 {
sample = out.Images[0]
}
if sample != "" {
base, options, _ := rule.ParseMediaOptions(sample)
for k, v := range s.MediaRequestHeaders(ctx, book, base, options.Headers) {
// 传输层头由客户端 HTTP 栈自己算,下发了反而可能冲突。
if transportHeader(k) {
continue
}
out.MediaHeaders[k] = v
}
}
}
// sourceAllowsMediaDirect 书源是否允许客户端直连取媒体(按书源 URL 查当前用户的副本)。
//
// 查不到书源时返回 false:宁可退回代理,也不把未确认归属的源推到客户端直连。
func (s *ReaderService) sourceAllowsMediaDirect(ctx context.Context, origin string) bool {
if s == nil || s.repo == nil || strings.TrimSpace(origin) == "" {
return false
}
src, err := s.repo.GetSourceAnyByURL(ctx, origin)
if err != nil || src == nil {
return false
}
return src.MediaDirect
}
// SetSourceMediaDirect 切换书源的媒体直连开关(限当前用户自己的书源)。
func (s *ReaderService) SetSourceMediaDirect(ctx context.Context, userID, id string, direct bool) error {
src, err := s.repo.GetSourceForUser(ctx, userID, id)
if err != nil {
return err
}
src.MediaDirect = direct
return s.repo.UpdateSource(ctx, src)
}
// transportHeader 判断是否是传输层头(由客户端 HTTP 栈负责,不应下发)。
func transportHeader(name string) bool {
switch strings.ToLower(name) {
case "accept-encoding", "connection", "host", "content-length", "cookie2":
return true
}
return false
}
// ProxyURL 将书源返回的媒体地址改写为签名代理地址。
// 签名 = HMAC-SHA256(jwtSecret, bookID|url),防止代理被滥用为开放中转。
//
@@ -1801,7 +2012,7 @@ func (s *ReaderService) ProxyCoverURL(id, rawURL string) string {
//
// id 为空时按书源 URL 签(搜索结果);传入书籍 ID 时按书签。
func (s *ReaderService) RewriteBookCover(ctx context.Context, id, origin, coverURL string) string {
cover := normalizeCoverURL(coverURL)
cover := normalizeCoverURL(coverURL, "")
if cover == "" || !strings.HasPrefix(cover, "http") {
return cover
}
@@ -1844,6 +2055,80 @@ func BodyIsPlaylist(head []byte, contentType string) bool {
return strings.Contains(contentType, "mpegurl") && strings.Contains(string(head), "#EXTM3U")
}
// MediaRequestHeaders 构造取上游媒体所需的完整请求头。
//
// 服务端拉流(FetchMediaWithOptions)与客户端直连(ContentOptions.MediaDirect)
// 共用这一份逻辑,保证两条链路的防盗链头、登录态与书源头完全一致 —— 直连只是
// 把「谁发起 HTTP 请求」从服务端换成 App,不应退化成一套更弱的请求。
//
// 优先级由低到高:通用预设 < 书源 header < 登录态 < URL 尾部选项。
func (s *ReaderService) MediaRequestHeaders(ctx context.Context, book *model.ReaderBook, rawURL string, options map[string]string) map[string]string {
out := map[string]string{}
for k, v := range helper.HTTPHeaderPresets() {
out[k] = v
}
if book == nil || s == nil {
return out
}
// 书源级请求头
if s.repo != nil {
if found, findErr := s.repo.GetSourceAnyByURL(ctx, book.Origin); findErr == nil && found != nil && found.Header != "" {
var headers map[string]any
if json.Unmarshal([]byte(found.Header), &headers) == nil {
for k, v := range headers {
out[k] = fmt.Sprintf("%v", v)
}
}
}
}
// 登录态:登录类书源的漫画/音频资源同样需要 Cookie 与 loginHeader 才能取到。
if s.repo != nil {
state := s.newSourceState(ctx, book.Origin)
for k, v := range state.LoginHeaderMap() {
if strings.EqualFold(k, "cookie") {
continue
}
if out[k] == "" {
out[k] = v
}
}
if out["Cookie"] == "" {
if ck := state.CookieForRequest(rawURL); ck != "" {
out["Cookie"] = ck
}
}
}
// URL 尾部选项里的请求头:优先级最高(逐图声明,取图必需)。
optionCookie := ""
for k, v := range options {
if strings.EqualFold(k, "Cookie") {
optionCookie = v
continue
}
out[k] = v
}
if optionCookie != "" {
// 与源 Cookie 合并时 options 优先(对应 legado AnalyzeUrl.setCookie 的临时 Cookie)。
if base := out["Cookie"]; base != "" {
out["Cookie"] = rule.MergeCookie(base, optionCookie)
} else {
out["Cookie"] = optionCookie
}
}
// 默认 Referer 只能用「真正的 http(s) 书源地址」。
// legado 的 origin 对普通书源是 bookSourceUrl,但聚合类书源(如「光遇聚合」)
// 的 origin 是个显示名,拼出来的 Referer 非法,会被图床判定为盗链并
// 301 到一张「请到本网站阅读」的占位图 —— 表现为漫画每一页都是同一张提示图。
// 这种情况下宁可不发 Referer(实测不带 Referer 能拿到原图);书源 header
// 里自己声明的 Referer 优先级更高,不受这里影响。
if out["Referer"] == "" {
if referer := sourceReferer(book.Origin); referer != "" {
out["Referer"] = referer
}
}
return out
}
// FetchMedia 用书源会话拉取媒体(不应用 URL 尾部选项)。
func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook, rawURL, rangeHeader string) (*http.Response, error) {
return s.FetchMediaWithOptions(ctx, book, rawURL, rangeHeader, nil)
@@ -1861,66 +2146,13 @@ func (s *ReaderService) FetchMediaWithOptions(ctx context.Context, book *model.R
if err != nil {
return nil, err
}
for k, v := range helper.HTTPHeaderPresets() {
for k, v := range s.MediaRequestHeaders(ctx, book, rawURL, options) {
httpReq.Header.Set(k, v)
}
// 媒体流同样交给 net/http 管压缩:否则压缩过的资源会以原始字节透传给
// 播放器/图片标签,表现为「打不开」。Range 请求服务端通常不压缩,
// 解压后 resp 会去掉 Content-Length/Content-Encoding,透传逻辑不受影响。
helper.StripAcceptEncoding(httpReq.Header)
// 书源级请求头
if s.repo != nil {
if found, findErr := s.repo.GetSourceAnyByURL(ctx, book.Origin); findErr == nil && found != nil && found.Header != "" {
var headers map[string]any
if json.Unmarshal([]byte(found.Header), &headers) == nil {
for k, v := range headers {
httpReq.Header.Set(k, fmt.Sprintf("%v", v))
}
}
}
}
// 登录态:登录类书源的漫画/音频资源同样需要 Cookie 与 loginHeader 才能取到。
if s.repo != nil {
state := s.newSourceState(ctx, book.Origin)
for k, v := range state.LoginHeaderMap() {
if !strings.EqualFold(k, "cookie") && httpReq.Header.Get(k) == "" {
httpReq.Header.Set(k, v)
}
}
if httpReq.Header.Get("Cookie") == "" {
if ck := state.CookieForRequest(rawURL); ck != "" {
httpReq.Header.Set("Cookie", ck)
}
}
}
// URL 尾部选项里的请求头:优先级最高(逐图声明,取图必需)。
optionCookie := ""
for k, v := range options {
if strings.EqualFold(k, "Cookie") {
optionCookie = v
continue
}
httpReq.Header.Set(k, v)
}
if optionCookie != "" {
// 与源 Cookie 合并时 options 优先(对应 legado AnalyzeUrl.setCookie 的临时 Cookie)。
if base := httpReq.Header.Get("Cookie"); base != "" {
httpReq.Header.Set("Cookie", rule.MergeCookie(base, optionCookie))
} else {
httpReq.Header.Set("Cookie", optionCookie)
}
}
// 默认 Referer 只能用「真正的 http(s) 书源地址」。
// legado 的 origin 对普通书源是 bookSourceUrl,但聚合类书源(如「光遇聚合」)
// 的 origin 是个显示名,拼出来的 Referer 非法,会被图床判定为盗链并
// 301 到一张「请到本网站阅读」的占位图 —— 表现为漫画每一页都是同一张提示图。
// 这种情况下宁可不发 Referer(实测不带 Referer 能拿到原图);书源 header
// 里自己声明的 Referer 优先级更高,不受这里影响。
if httpReq.Header.Get("Referer") == "" {
if referer := sourceReferer(book.Origin); referer != "" {
httpReq.Header.Set("Referer", referer)
}
}
if rangeHeader != "" {
httpReq.Header.Set("Range", rangeHeader)
}
@@ -1942,12 +2174,14 @@ func (s *ReaderService) FetchMediaWithOptions(ctx context.Context, book *model.R
if err != nil {
return nil, err
}
resp, err := s.http.Do(httpReq)
// 媒体用无整体超时的 client:4MB 音频在跨境链路上常超过 30 秒,
// 用带整体超时的 client 会在读完 body 前被掐断(静默截断)。
resp, err := s.mediaClient().Do(httpReq)
if err != nil {
lastErr = err
continue
}
if attempt == mediaFetchAttempts || !retryableStatus(resp.StatusCode) {
if attempt == mediaFetchAttempts || !mediaRetryableStatus(resp.StatusCode) {
return resp, nil
}
// 可重试的状态码:读完并关闭响应体再试,避免连接泄漏。
@@ -2060,10 +2294,13 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
return nil, req.Unsupported
}
sess.applyHeaders(req, runner)
body, finalURL, _, err := sess.fetch(req)
body, finalURL, code, err := sess.fetch(req)
if err != nil {
return nil, err
}
if err := upstreamStatusError(code); err != nil {
return nil, err
}
lastFinalURL = finalURL
ar := sess.newAnalyzer("", 0, body, finalURL)
sess.applyBookContext(ar, bookURL, sess.book, "", 0)
@@ -2511,7 +2748,7 @@ func (s *ReaderService) fetchSourceResource(ctx context.Context, sourceURL, rawU
httpReq.Header.Set("Referer", referer)
}
}
return s.http.Do(httpReq)
return s.mediaClient().Do(httpReq)
}
// ─── 书架 ──────────────────────────────────────────────────────────────────
@@ -2528,7 +2765,7 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc
BookURL: origin.BookURL,
Name: name,
Author: author,
CoverURL: normalizeCoverURL(coverURL),
CoverURL: normalizeCoverURL(coverURL, origin.BookURL),
Type: origin.OriginType,
}
if err := s.repo.CreateBook(ctx, book); err != nil {
@@ -2589,7 +2826,7 @@ func (s *ReaderService) SwitchOrigin(ctx context.Context, userID, bookID string,
if info != nil {
book.TocURL = strings.TrimSpace(info.TocURL)
if book.CoverURL == "" {
book.CoverURL = normalizeCoverURL(info.CoverURL)
book.CoverURL = normalizeCoverURL(info.CoverURL, target)
}
if info.LatestChapter != "" {
book.LatestChapterTitle = info.LatestChapter
@@ -3002,14 +3239,16 @@ func (s *ReaderService) DeleteReplaceRule(ctx context.Context, userID, id string
// GetContentForBook 按书架书籍 + 章节序号取正文:
// 解析书源 → 持久缓存(命中直接返回)→ 抓正文(单飞)→ 写缓存 →
// 书源 replaceRegex → 用户替换净化规则 → 签名代理改写。
func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID string, chapterIndex int) (*ChapterContent, error) {
//
// opts 为零值时行为与历史版本完全一致(媒体全部走服务端代理)。
func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID string, chapterIndex int, opts ContentOptions) (*ChapterContent, error) {
book, err := s.repo.GetBook(ctx, bookID)
if err != nil {
return nil, err
}
// 本地导入书籍:正文直接从本地文件读,不走书源
if book.LocalPath != "" {
return s.LocalChapterContent(ctx, userID, bookID, chapterIndex)
return s.localChapterContentWithOptions(ctx, userID, bookID, chapterIndex, opts)
}
chapters, err := s.repo.ListChapters(ctx, bookID)
if err != nil {
@@ -3046,22 +3285,21 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
if out.Type == "text" {
out.Content = s.applyUserReplaceRules(ctx, userID, book.Name, out.Content)
}
// 音频/图片地址改写为签名代理(浏览器播放/展示无法带防盗链头)
for i, t := range out.Tracks {
out.Tracks[i] = s.ProxyURL(book.ID, t)
}
for i, img := range out.Images {
out.Images[i] = s.ProxyURL(book.ID, img)
}
// 文本型漫画源:正文里的 <img> 标签换成前端认识的 [img] 标记(同样走签名代理,
// 这样需要防盗链头的图片也能显示)。见 rewriteContentImageMarkers 的说明。
direct := s.mediaDirectFor(ctx, book.Origin, opts)
// 文本型漫画源:正文里的 <img> 标签换成前端认识的 [img] 标记。
// 默认仍走签名代理,这样需要防盗链头的图片也能显示(见
// rewriteContentImageMarkers 的说明);直连时保留上游原始地址。
if out.Type == "text" && strings.Contains(out.Content, "<img") {
out.Content = rewriteContentImageMarkers(out.Content, ch.URL, func(u string) string {
if direct {
return u
}
return s.ProxyURL(book.ID, u)
})
}
// 整章都是 [img] 标记 → 这就是一章漫画,改成图片类型交给漫画阅读器。
// 若还按文本下发,前端会把它当文字按列分页,一屏只有一张图(双页铺开也无从谈起)。
// 必须早于 applyMediaURLs:转换出来的图片地址同样要参与代理/直连决策与请求头计算。
if out.Type == "text" {
if imgs, ok := imageMarkersOnly(out.Content); ok {
out.Type = "image"
@@ -3069,6 +3307,9 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
out.Content = ""
}
}
// 音频/图片地址改写:默认走签名代理(浏览器播放/展示无法带防盗链头);
// 客户端显式请求直连且书源允许时,保留上游地址并下发所需请求头。
s.applyMediaURLs(ctx, book, out, direct)
return out, nil
}
@@ -3150,10 +3391,14 @@ type ContentBatchItem struct {
Images []string `json:"images,omitempty"`
ImageStyle string `json:"image_style,omitempty"`
IsHLS bool `json:"hls,omitempty"`
Transcoding bool `json:"transcoding,omitempty"`
Comments []ContentComment `json:"comments,omitempty"`
// Cached 为 true 表示这次由持久缓存直接命中(未访问书源)。
Cached bool `json:"cached"`
Error string `json:"error,omitempty"`
// MediaDirect / MediaHeaders 同 ChapterContent:客户端直连时的地址与请求头。
MediaDirect bool `json:"media_direct,omitempty"`
MediaHeaders map[string]string `json:"media_headers,omitempty"`
}
// ContentBatchResult 批量取正文的汇总结果。
@@ -3172,7 +3417,7 @@ const contentBatchConcurrency = 4
// GetContentBatch 批量取正文(对应 legado 的 CacheBook 离线缓存协议)。
//
// 单章失败不中断整批:失败项带 error,其余照常返回。已缓存的章节不访问书源。
func (s *ReaderService) GetContentBatch(ctx context.Context, userID, bookID string, indexes []int, applyReplace bool) (*ContentBatchResult, error) {
func (s *ReaderService) GetContentBatch(ctx context.Context, userID, bookID string, indexes []int, applyReplace bool, opts ContentOptions) (*ContentBatchResult, error) {
book, err := s.repo.GetBook(ctx, bookID)
if err != nil {
return nil, err
@@ -3208,7 +3453,7 @@ func (s *ReaderService) GetContentBatch(ctx context.Context, userID, bookID stri
result.Items[pos] = ContentBatchItem{ChapterIndex: idx, Error: ctx.Err().Error()}
return
}
item, cached := s.loadBatchItem(ctx, userID, book, chapters[idx], applyReplace)
item, cached := s.loadBatchItem(ctx, userID, book, chapters[idx], applyReplace, opts)
result.Items[pos] = item
if item.Error != "" {
result.Stats.Failed++
@@ -3224,18 +3469,22 @@ func (s *ReaderService) GetContentBatch(ctx context.Context, userID, bookID stri
}
// loadBatchItem 取单章(批量接口用):先查缓存(不访问书源),再走完整链路。
func (s *ReaderService) loadBatchItem(ctx context.Context, userID string, book *model.ReaderBook, ch model.ReaderChapter, applyReplace bool) (ContentBatchItem, bool) {
func (s *ReaderService) loadBatchItem(ctx context.Context, userID string, book *model.ReaderBook, ch model.ReaderChapter, applyReplace bool, opts ContentOptions) (ContentBatchItem, bool) {
item := ContentBatchItem{ChapterIndex: ch.Index}
src, bs, loadErr := s.loadSourceFlexible(ctx, "", book.Origin)
cached := false
if loadErr == nil {
if out, ok := s.loadCachedContent(ctx, src, bs, book, ch, ""); ok {
// 缓存里存的是书源侧原始地址(改写前),命中后同样要按本次请求
// 决定代理/直连,否则缓存命中的章节会绕过媒体通道选择。
direct := s.mediaDirectFor(ctx, book.Origin, opts)
s.applyMediaURLs(ctx, book, out, direct)
item = batchItemFromContent(ch.Index, out, applyReplace, userID, book, s)
cached = true
}
}
if !cached {
out, err := s.GetContentForBook(ctx, userID, book.ID, ch.Index)
out, err := s.GetContentForBook(ctx, userID, book.ID, ch.Index, opts)
if err != nil {
item.Error = err.Error()
return item, false
@@ -3255,7 +3504,10 @@ func batchItemFromContent(index int, out *ChapterContent, applyReplace bool, use
Images: out.Images,
ImageStyle: out.ImageStyle,
IsHLS: out.IsHLS,
Transcoding: out.Transcoding,
Comments: out.Comments,
MediaDirect: out.MediaDirect,
MediaHeaders: out.MediaHeaders,
}
if applyReplace && out.Type == "text" && s != nil && book != nil {
item.Content = s.applyUserReplaceRules(context.Background(), userID, book.Name, item.Content)
@@ -123,7 +123,7 @@ func TestContentCacheHitAndPersist(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
first, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
first, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("首次读取失败: %v", err)
}
@@ -134,7 +134,7 @@ func TestContentCacheHitAndPersist(t *testing.T) {
t.Fatalf("首次读取应访问书源 1 次,实际 %d", got)
}
second, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
second, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("二次读取失败: %v", err)
}
@@ -166,7 +166,7 @@ func TestContentCacheSurvivesTocRefresh(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1, ContentOptions{}); err != nil {
t.Fatalf("读取第 2 章失败: %v", err)
}
@@ -190,7 +190,7 @@ func TestContentCacheSurvivesTocRefresh(t *testing.T) {
newChapters, _ := svc.repo.ListChapters(ctx, book.ID)
svc.RemapContentCacheOnTocChange(ctx, book, oldChapters, newChapters)
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1, ContentOptions{}); err != nil {
t.Fatalf("刷新后读取失败: %v", err)
}
if got := srv.count(2); got != 1 {
@@ -209,7 +209,7 @@ func TestContentCacheSingleFlight(t *testing.T) {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, errs[i] = svc.GetContentForBook(ctx, "u1", book.ID, 0)
_, errs[i] = svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
}(i)
}
wg.Wait()
@@ -229,14 +229,14 @@ func TestContentBatchStatsAndFailureIsolation(t *testing.T) {
ctx := t.Context()
// 预热第 1 章(进缓存),第 2 章按失败处理。
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{}); err != nil {
t.Fatalf("预热失败: %v", err)
}
srv.mu.Lock()
srv.failOn[2] = true
srv.mu.Unlock()
result, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{0, 1, 2}, false)
result, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{0, 1, 2}, false, ContentOptions{})
if err != nil {
t.Fatalf("批量读取失败: %v", err)
}
@@ -262,14 +262,14 @@ func TestContentBatchStatsAndFailureIsolation(t *testing.T) {
srv.mu.Lock()
srv.failOn[2] = false
srv.mu.Unlock()
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1, ContentOptions{}); err != nil {
t.Fatalf("恢复后读取第 2 章失败: %v", err)
}
if got := srv.count(2); got != 2 {
t.Fatalf("失败章不应进缓存,恢复后应重抓(2 次),实际 %d", got)
}
// 越界/卷章节被忽略。
if got, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{-1, 99}, false); err != nil || len(got.Items) != 0 {
if got, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{-1, 99}, false, ContentOptions{}); err != nil || len(got.Items) != 0 {
t.Fatalf("越界索引应被忽略: items=%d err=%v", len(got.Items), err)
}
}
@@ -279,7 +279,7 @@ func TestContentCacheInvalidatedOnSourceUpdate(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{}); err != nil {
t.Fatalf("首次读取失败: %v", err)
}
// 重新导入同一书源(RawJSON 变化)→ 指纹变化。
@@ -287,7 +287,7 @@ func TestContentCacheInvalidatedOnSourceUpdate(t *testing.T) {
if _, err := svc.ImportSources(ctx, "u1", updated); err != nil {
t.Fatalf("更新书源失败: %v", err)
}
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{}); err != nil {
t.Fatalf("更新后读取失败: %v", err)
}
if got := srv.count(1); got != 2 {
@@ -299,7 +299,7 @@ func TestContentCacheInvalidatedOnSourceUpdate(t *testing.T) {
func TestContentCacheClearedOnRemoveBook(t *testing.T) {
svc, _, book := prepareCacheTestBook(t)
ctx := t.Context()
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{}); err != nil {
t.Fatalf("读取失败: %v", err)
}
sourceID := svc.contentSourceID(ctx, nil, book)
@@ -395,7 +395,7 @@ func TestContentCacheWithoutCacheDir(t *testing.T) {
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"}}); err != nil {
t.Fatal(err)
}
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("无缓存目录时读取失败: %v", err)
}
+1 -1
View File
@@ -220,7 +220,7 @@ func TestEndToEndSourceChain(t *testing.T) {
func TestGetContentForBookEmptyContentFails(t *testing.T) {
svc, bookID := setupTextChapterBook(t, "")
_, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
_, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0, ContentOptions{})
if err == nil {
t.Fatal("空正文应当报错,实际当成功下发了")
}
@@ -0,0 +1,82 @@
package reader
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// 上游限流(HTTP 429)在免费/低配后端上是常态。书源没声明 retry 时,
// GET 也会自动补一次:第一次 429、第二次正常时,详情必须能拿到数据。
func TestGetBookInfoRetriesUpstream429(t *testing.T) {
upstream := e2eServer()
defer upstream.Close()
var mu sync.Mutex
hits := map[string]int{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
hits[r.URL.Path]++
n := hits[r.URL.Path]
mu.Unlock()
if n == 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"code":"projectConcurrencyLimitExceeded","message":"当前访问人数较多"}`))
return
}
resp, err := http.Get(upstream.URL + r.URL.RequestURI())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.WriteHeader(resp.StatusCode)
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, _ = w.Write(buf[:n])
}
if err != nil {
return
}
}
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
info, err := svc.GetBookInfo(t.Context(), sourceID, "", srv.URL+"/book/1")
if err != nil {
t.Fatalf("第一次 429、重试后应成功: %v", err)
}
if info.Name == "" {
t.Fatalf("重试后详情应有书名,实际为空")
}
}
// 持续限流时必须报错,而不是把 429 的响应体解析成「空目录 / 0 章」。
func TestGetTocReportsUpstream429(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"code":"projectConcurrencyLimitExceeded","message":"当前访问人数较多"}`))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
_, err := svc.GetToc(t.Context(), "u1", sourceID, srv.URL, srv.URL+"/book/1", srv.URL+"/book/1")
if err == nil {
t.Fatalf("持续 429 时应返回错误,而不是空目录")
}
if !strings.Contains(err.Error(), "429") {
t.Fatalf("错误信息应包含 429,实际: %v", err)
}
}
-7
View File
@@ -126,13 +126,6 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
}
cfg.Cache.ImagesOriginalsMaxSizeMB = n
}
case "cache.images_originals_ttl_hours":
if n, err := strconv.Atoi(value); err == nil {
if n < 0 {
n = 0
}
cfg.Cache.ImagesOriginalsTTLHours = n
}
case "cache.memory_max_size_mb":
n := config.DefaultCacheMemoryMaxSizeMB
if value != "" {
+34 -12
View File
@@ -8,6 +8,9 @@
// organize_source opt-in — organize the configured staging folder.
// transcode_cleanup every 24 h — purge HLS transcode artefacts
// older than 24 h.
// cache_cleanup every 1 h — enforce the global disk-cache capacity
// (hls → images → library-covers; reader-audio
// has its own 1 GB eviction on write).
// segment_prewarm every 6 h — fill IntroDB skip segments for queryable media.
//
// Each job runs at most once at a time (an in-flight run blocks the
@@ -41,7 +44,7 @@ type SchedulerService struct {
cacheDir string
now func() time.Time
imagesPolicyProvider func() ImageCachePolicy
cachePolicyProvider func() CacheCleanupPolicy
// readerContentCleaner 阅读正文缓存的清理钩子(由阅读模块注入)。
readerContentCleaner func(context.Context)
@@ -52,6 +55,9 @@ type SchedulerService struct {
segments *MediaSegmentService
backup *BackupService
// cloudDownload 用于周期轮询 115 云下载任务状态(nil 表示不启用)。
cloudDownload *CloudDownloadService
mu sync.Mutex
stopCh chan struct{}
jobs []*scheduledJob
@@ -70,6 +76,11 @@ func (s *SchedulerService) SetBackupService(backup *BackupService) {
s.backup = backup
}
// SetCloudDownload 注入云下载服务:注入后才注册轮询任务。
func (s *SchedulerService) SetCloudDownload(cd *CloudDownloadService) {
s.cloudDownload = cd
}
func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
s.organizePipeline = pipeline
}
@@ -79,22 +90,24 @@ func (s *SchedulerService) SetExpiryWatcher(watcher *TelegramExpiryWatcher) {
s.expiryWatcher = watcher
}
// ImageCachePolicy 是一次图片缓存清理要用的策略(全部为 0 表示不做任何清理)。
type ImageCachePolicy struct {
// CacheCleanupPolicy 是一次全缓存容量清理要用的策略(全部为 0 表示不做任何清理)。
// TotalBytes 是 hls / images / library-covers 三个池的总容量上限;OriginalsBytes
// 是 images 原图池的独立子配额。reader-audio 由自身 1GB 配额管理,不在此列。
// 只有容量配额,没有保留时长:淘汰只看占用,不按文件年龄删除。
type CacheCleanupPolicy struct {
TotalBytes int64
OriginalsBytes int64
OriginalsAge time.Duration
}
func (s *SchedulerService) SetImageCachePolicyProvider(fn func() ImageCachePolicy) {
s.imagesPolicyProvider = fn
func (s *SchedulerService) SetCacheCleanupPolicyProvider(fn func() CacheCleanupPolicy) {
s.cachePolicyProvider = fn
}
func (s *SchedulerService) imageCachePolicy() ImageCachePolicy {
if s.imagesPolicyProvider != nil {
return s.imagesPolicyProvider()
func (s *SchedulerService) cacheCleanupPolicy() CacheCleanupPolicy {
if s.cachePolicyProvider != nil {
return s.cachePolicyProvider()
}
return ImageCachePolicy{}
return CacheCleanupPolicy{}
}
// SetReaderContentCleaner 注入阅读正文缓存的清理钩子(TTL 过期 + 容量 LRU)。
@@ -166,9 +179,9 @@ func (s *SchedulerService) Start(ctx context.Context) {
run: s.jobCleanTranscodeCache,
},
{
name: "image_cache_cleanup",
name: "cache_cleanup",
interval: 1 * time.Hour,
run: s.jobCleanImageCache,
run: s.jobCleanCache,
},
}
// 阅读正文缓存清理只在注入钩子后注册(见 SetReaderContentCleaner)。
@@ -208,6 +221,15 @@ func (s *SchedulerService) Start(ctx context.Context) {
run: s.jobDatabaseBackup,
})
}
// 云下载轮询:只有注入了云下载服务才注册,避免旧部署跑空转任务。
// 间隔可配(cloud_download.poll_interval_sec),由服务自己读取。
if s.cloudDownload != nil {
s.jobs = append(s.jobs, &scheduledJob{
name: "cloud_download_poll",
interval: s.cloudDownload.pollInterval(),
run: s.jobCloudDownloadPoll,
})
}
for _, j := range s.jobs {
initialDelay := 15 * time.Second
if j.name == "library_scan" || j.name == "organize_source" || j.name == "segment_prewarm" {
@@ -0,0 +1,16 @@
// 调度器:云下载任务轮询。
//
// 单独成文件而不是塞进 scheduler_local_jobs.go:那个文件是本地媒体相关的
// 周期任务,云下载是外部网盘任务,两者的失败处理与限流策略不同。
package service
import "context"
// jobCloudDownloadPoll 轮询 115 云下载任务状态。
// 没有活跃任务时 PollOnce 直接返回,不发任何 115 请求。
func (s *SchedulerService) jobCloudDownloadPoll(ctx context.Context) error {
if s.cloudDownload == nil {
return nil
}
return s.cloudDownload.PollOnce(ctx)
}

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