mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-13 03:44:23 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08d521b65d | |||
| 5806839601 | |||
| d7c150ffd9 | |||
| 79943d9067 | |||
| 9ffac320c4 | |||
| 9b8ca33279 | |||
| 34e61a5994 | |||
| d91ded9d55 | |||
| 8458b8060a | |||
| 623d87f12b |
@@ -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` |
|
||||
@@ -45,7 +45,7 @@ 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.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
|
||||
v.SetDefault("cache.redis_url", "")
|
||||
|
||||
@@ -120,8 +120,13 @@ 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"`
|
||||
|
||||
@@ -89,7 +89,7 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
@@ -9,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -97,6 +100,99 @@ func TestReaderMediaProxyFailsClearlyWhenRefreshImpossible(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -270,9 +366,16 @@ func TestReaderAudioCacheServesFromDisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderAudioCacheRejectsTruncatedUpstream 音轨拉不完整时重试;全部失败回 502,
|
||||
// 且不能留下半截缓存文件(否则后续会把截断的音频当完整文件下发)。
|
||||
func TestReaderAudioCacheRejectsTruncatedUpstream(t *testing.T) {
|
||||
// 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")
|
||||
@@ -297,15 +400,43 @@ func TestReaderAudioCacheRejectsTruncatedUpstream(t *testing.T) {
|
||||
// 无后缀地址 + 音频书籍 → 走音轨落盘缓存。
|
||||
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)
|
||||
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("失败的下载不应留下缓存文件")
|
||||
t.Fatalf("失败的续传仍不应留下完整缓存")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +459,7 @@ func getReaderMedia(t *testing.T, router *gin.Engine, signedPath string) (int, s
|
||||
// primeTicketBook 先读一次章节(签出第一张票并落缓存),返回该音轨的签名代理路径。
|
||||
func primeTicketBook(t *testing.T, svc *service.Container, bookID string) string {
|
||||
t.Helper()
|
||||
out, err := svc.Reader.GetContentForBook(t.Context(), "u1", bookID, 0)
|
||||
out, err := svc.Reader.GetContentForBook(t.Context(), "u1", bookID, 0, reader.ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("首次取章节失败: %v", err)
|
||||
}
|
||||
@@ -347,12 +478,13 @@ func primeTicketBook(t *testing.T, svc *service.Container, bookID string) string
|
||||
// - /img/* 图片资源,固定 403(模拟防盗链)
|
||||
type mediaTicketServer struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
issued []string
|
||||
hits int
|
||||
mediaHits int
|
||||
noToken bool
|
||||
errorPage bool
|
||||
mu sync.Mutex
|
||||
issued []string
|
||||
hits int
|
||||
mediaHits int
|
||||
noToken bool
|
||||
errorPage bool
|
||||
alwaysDeny bool
|
||||
}
|
||||
|
||||
func newMediaTicketServer(t *testing.T) *mediaTicketServer {
|
||||
@@ -379,7 +511,13 @@ func newMediaTicketServer(t *testing.T) *mediaTicketServer {
|
||||
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>`))
|
||||
@@ -435,6 +573,13 @@ func (s *mediaTicketServer) serveErrorPage() {
|
||||
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()
|
||||
@@ -523,3 +668,97 @@ func prepareMediaTicketBook(t *testing.T) (*service.Container, *mediaTicketServe
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -131,16 +132,27 @@ func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
// MediaDirect 媒体直连开关:允许客户端自行下载音频/漫画图片字节。
|
||||
// 可单独提交,也可与其他字段一起提交(只更新出现的字段)。
|
||||
MediaDirect *bool `json:"media_direct"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Enabled == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
|
||||
if err := c.ShouldBindJSON(&body); err != nil || (body.Enabled == nil && body.MediaDirect == nil) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 或 media_direct 字段必填"})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), userID, c.Param("id"), *body.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
if body.Enabled != nil {
|
||||
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), userID, c.Param("id"), *body.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.MediaDirect != nil {
|
||||
if err := svc.Reader.SetSourceMediaDirect(c.Request.Context(), userID, c.Param("id"), *body.MediaDirect); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -993,12 +1005,8 @@ func streamReaderMediaBody(w io.Writer, body io.Reader) {
|
||||
|
||||
const (
|
||||
// readerAudioCacheAttempts 首次落盘的最多尝试次数(每次内部都会走「失败换票」)。
|
||||
// 续传只补没下完的部分,所以这里的次数比过去更可用:每次失败都能推进进度。
|
||||
readerAudioCacheAttempts = 4
|
||||
// readerAudioCacheRetryDelay 重试退避基数,实际等待为 (attempt-1) 倍。
|
||||
readerAudioCacheRetryDelay = 500 * time.Millisecond
|
||||
// readerAudioCacheMaxBytes 单个音频的落盘上限。有声书一章约 3–5MB,
|
||||
// 给到 64MB 足够容纳长章节,也能挡住误当成音频的超大对象。
|
||||
readerAudioCacheMaxBytes = 64 << 20
|
||||
// readerMediaReissueAttempts 换票重试次数:跨境 CDN 的「空 401」是概率性的,
|
||||
// 一张票不行往往换一张就好;旧实现只换一次,第二次失败就把 502 交给播放器。
|
||||
readerMediaReissueAttempts = 3
|
||||
@@ -1008,8 +1016,123 @@ const (
|
||||
// readerAudioFetchGroup 同一音轨并发请求(播放器 + 预取 + 预热)只下载一次。
|
||||
var readerAudioFetchGroup singleflight.Group
|
||||
|
||||
// handleCachedReaderAudio 音频直链的统一入口:先查本地缓存,未命中则整段拉取落盘,
|
||||
// 最后交给 http.ServeContent 处理 Range(拖动进度、续播都读本地文件)。
|
||||
// readerAudioStreamLocks 首播直通按缓存键串行化。
|
||||
//
|
||||
// 直通模式下两个并发请求会同时写同一个 .part 文件(播放器 + 预热器各自开一条
|
||||
// 连接很常见),交错写入必然把文件写坏。这里让后到者排队等前一个下完,
|
||||
// 拿到锁后再查一次缓存直接下发——语义与原来 singleflight 等价,只是领导者
|
||||
// 是在流式外发的,追随者不需要重复下载。
|
||||
var readerAudioStreamLocks = newKeyedMutex()
|
||||
|
||||
// keyedMutex 按字符串键串行化,锁释放后回收条目,避免 map 无限增长。
|
||||
type keyedMutex struct {
|
||||
mu sync.Mutex
|
||||
locks map[string]*keyedMutexEntry
|
||||
}
|
||||
|
||||
type keyedMutexEntry struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
func newKeyedMutex() *keyedMutex {
|
||||
return &keyedMutex{locks: map[string]*keyedMutexEntry{}}
|
||||
}
|
||||
|
||||
// Lock 取得该键的独占锁,返回解锁函数(必须调用)。
|
||||
func (k *keyedMutex) Lock(key string) func() {
|
||||
k.mu.Lock()
|
||||
entry := k.locks[key]
|
||||
if entry == nil {
|
||||
entry = &keyedMutexEntry{}
|
||||
k.locks[key] = entry
|
||||
}
|
||||
entry.refs++
|
||||
k.mu.Unlock()
|
||||
|
||||
entry.mu.Lock()
|
||||
return func() {
|
||||
entry.mu.Unlock()
|
||||
k.mu.Lock()
|
||||
entry.refs--
|
||||
if entry.refs == 0 {
|
||||
delete(k.locks, key)
|
||||
}
|
||||
k.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// keyedMutexPollInterval 等锁的重试间隔。只用于「已有写入者时短暂等待」,
|
||||
// 不值得为它引入条件变量。
|
||||
const keyedMutexPollInterval = 20 * time.Millisecond
|
||||
|
||||
// LockWait 最多等 timeout 就放弃。
|
||||
//
|
||||
// 音频缓存的写入者可能是「首播直通」,它会一直持锁到客户端读完整章;让后来的
|
||||
// 请求排队等它,播放器的重试就会干等几十秒(用户看到的就是「一直转圈」)。
|
||||
// 等不到锁的请求改走纯透传,字节照样能出声,只是这次不落盘。
|
||||
func (k *keyedMutex) LockWait(key string, timeout time.Duration) (func(), bool) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if unlock, ok := k.acquire(key); ok {
|
||||
return unlock, true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return nil, false
|
||||
}
|
||||
time.Sleep(keyedMutexPollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// acquire 取一次锁,取不到立刻返回。引用计数保证条目在最后一个持有者归还后回收。
|
||||
func (k *keyedMutex) acquire(key string) (func(), bool) {
|
||||
k.mu.Lock()
|
||||
entry := k.locks[key]
|
||||
if entry == nil {
|
||||
entry = &keyedMutexEntry{}
|
||||
k.locks[key] = entry
|
||||
}
|
||||
entry.refs++
|
||||
k.mu.Unlock()
|
||||
|
||||
if !entry.mu.TryLock() {
|
||||
k.release(key, entry)
|
||||
return nil, false
|
||||
}
|
||||
return func() {
|
||||
entry.mu.Unlock()
|
||||
k.release(key, entry)
|
||||
}, true
|
||||
}
|
||||
|
||||
// release 归还一次引用,无人持有时回收条目。
|
||||
func (k *keyedMutex) release(key string, entry *keyedMutexEntry) {
|
||||
k.mu.Lock()
|
||||
entry.refs--
|
||||
if entry.refs == 0 {
|
||||
delete(k.locks, key)
|
||||
}
|
||||
k.mu.Unlock()
|
||||
}
|
||||
|
||||
// readerAudioLockWait 单条音轨的写锁最多等多久;等不到就降级成纯透传。
|
||||
const readerAudioLockWait = 500 * time.Millisecond
|
||||
|
||||
// handleCachedReaderAudio 音频直链的统一入口。
|
||||
//
|
||||
// 四条路径,按代价从低到高:
|
||||
// 1. 命中完整缓存 → http.ServeContent 按 Range 下发(拖动、续播都读本地);
|
||||
// 2. 带 Range 的冷缓存 → 区间直通(serveReaderAudioRange):客户端要哪一段就
|
||||
// 从上游边下边发哪一段,同时补全分片。**播放器嗅探完容器一定会带 Range 重开
|
||||
// 一次**(media3 的固有行为),所以这条才是真正的起播路径。旧实现要求整章
|
||||
// 下完才能按区间下发,每章起播都要等 1–4MB 跨境回源,实测 1.5–35 秒;
|
||||
// 3. 冷启动整段请求 → 边下边发给客户端,同时落盘(streamReaderAudioInto):
|
||||
// 播放器嗅探容器的那次 200 请求,几百毫秒就能出声,落盘照旧;
|
||||
// 4. 已有 .part 分片的整段请求 → 走整段下载:分片意味着上次已经下到一半,
|
||||
// 同步续传剩余字节比重新直通更省流量,下完再一次性下发。
|
||||
//
|
||||
// 同一条音轨同时只允许一个写入者。等不到写锁的请求降级成纯透传(不排队、不
|
||||
// 落盘):首播直通会一直持锁到客户端读完整章,让它排队等于让播放器的重试干等。
|
||||
func handleCachedReaderAudio(
|
||||
c *gin.Context,
|
||||
svc *service.Container,
|
||||
@@ -1022,6 +1145,39 @@ func handleCachedReaderAudio(
|
||||
serveCachedReaderAudio(c, f, info, rawURL)
|
||||
return
|
||||
}
|
||||
cacheKey := svc.Reader.RemoteAudioCachePath(bookID, rawURL)
|
||||
if cacheKey == "" {
|
||||
c.String(http.StatusBadGateway, "音频缓存不可用")
|
||||
return
|
||||
}
|
||||
rangeHeader := strings.TrimSpace(c.GetHeader("Range"))
|
||||
unlock, locked := readerAudioStreamLocks.LockWait(cacheKey, readerAudioLockWait)
|
||||
if locked {
|
||||
defer unlock()
|
||||
// 排队期间前一个请求可能已经把整章下完:直接下发本地文件,不重复回源。
|
||||
if f, info, ok := svc.Reader.OpenCachedRemoteAudio(bookID, rawURL); ok {
|
||||
defer f.Close()
|
||||
serveCachedReaderAudio(c, f, info, rawURL)
|
||||
return
|
||||
}
|
||||
}
|
||||
if rangeHeader != "" && serveReaderAudioRange(c, svc, book, bookID, rawURL, headers, rangeHeader, locked) {
|
||||
return
|
||||
}
|
||||
if !locked {
|
||||
proxyReaderAudio(c, svc, book, bookID, rawURL, headers, rangeHeader)
|
||||
return
|
||||
}
|
||||
// 播放器拖进度时会带 Range。已缓存的文件才谈得上 Range;未缓存时直通
|
||||
// 只能从 0 发,否则客户端拿到的区间与它请求的对不上。
|
||||
canStream := c.Request.Method == http.MethodGet &&
|
||||
rangeHeader == "" &&
|
||||
!svc.Reader.HasPartialRemoteAudio(bookID, rawURL)
|
||||
if canStream {
|
||||
streamReaderAudioInto(c, svc, book, bookID, rawURL, headers)
|
||||
return
|
||||
}
|
||||
|
||||
cachePath, err := ensureCachedRemoteAudio(c.Request.Context(), svc, book, bookID, rawURL, headers)
|
||||
if err != nil {
|
||||
if svc.Log != nil {
|
||||
@@ -1044,6 +1200,349 @@ func handleCachedReaderAudio(
|
||||
serveCachedReaderAudio(c, f, info, rawURL)
|
||||
}
|
||||
|
||||
// serveReaderAudioRange 区间直通:把客户端请求的那一段从上游边下边发。
|
||||
//
|
||||
// persist 表示调用方已拿到写锁,可以顺手把字节补进分片(落盘后下次拖动/重播
|
||||
// 直接读本地)。返回 false 表示这次没法按区间处理(多区间、后缀区间、上游不认
|
||||
// Range 且总长未知等),调用方应退回整段路径。
|
||||
func serveReaderAudioRange(
|
||||
c *gin.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
rangeHeader string,
|
||||
persist bool,
|
||||
) bool {
|
||||
start, end, ok := parseReaderAudioRange(rangeHeader)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// 分片里已经躺着 [0, partLen) 的字节:起点在分片内就先把这段发出去,再从
|
||||
// partLen 处回源续下。起点比分片还靠后说明中间有空洞,拼不上,只能从起点
|
||||
// 重新取,并且不落盘(否则分片会被写成带洞的文件)。
|
||||
var partFile *os.File
|
||||
partLen := int64(0)
|
||||
upstreamStart := start
|
||||
if f, size, okPart := svc.Reader.PartialRemoteAudio(bookID, rawURL); okPart {
|
||||
partFile = f
|
||||
partLen = size
|
||||
if partLen > start {
|
||||
upstreamStart = partLen
|
||||
}
|
||||
}
|
||||
// 收尾时关掉分片句柄:Windows 下「文件还开着就 rename」会被共享冲突挡住,
|
||||
// 提升分片为正式缓存之前必须先关。
|
||||
defer func() {
|
||||
if partFile != nil {
|
||||
_ = partFile.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := fetchReaderMediaResilient(c.Request.Context(), svc, book, rawURL,
|
||||
fmt.Sprintf("bytes=%d-", upstreamStart), headers, false)
|
||||
if err != nil || out == nil || out.resp == nil {
|
||||
if svc.Log != nil && err != nil {
|
||||
svc.Log.Warn("reader 音频区间直通失败", zap.String("book", bookID), zap.Error(err))
|
||||
}
|
||||
return false
|
||||
}
|
||||
resp := out.resp
|
||||
|
||||
total := int64(-1)
|
||||
switch resp.StatusCode {
|
||||
case http.StatusPartialContent:
|
||||
crStart, crTotal, crOK := reader.ParseContentRange(resp.Header.Get("Content-Range"))
|
||||
if !crOK || crTotal <= 0 || crStart != upstreamStart {
|
||||
drainReaderMediaBody(resp)
|
||||
return false
|
||||
}
|
||||
total = crTotal
|
||||
case http.StatusOK:
|
||||
// 上游忽略了 Range,整段从头给:把前缀读掉丢弃,后面的字节与分片仍然
|
||||
// 对得上(前缀正好也是 [0, upstreamStart)),所以落盘可以照旧。
|
||||
if resp.ContentLength <= 0 {
|
||||
drainReaderMediaBody(resp)
|
||||
return false
|
||||
}
|
||||
if upstreamStart > 0 {
|
||||
if _, copyErr := io.CopyN(io.Discard, resp.Body, upstreamStart); copyErr != nil {
|
||||
drainReaderMediaBody(resp)
|
||||
return false
|
||||
}
|
||||
}
|
||||
total = resp.ContentLength
|
||||
case http.StatusRequestedRangeNotSatisfiable:
|
||||
// 分片比对象还长(对象换过):丢掉分片走整段路径重来。
|
||||
drainReaderMediaBody(resp)
|
||||
return false
|
||||
default:
|
||||
drainReaderMediaBody(resp)
|
||||
return false
|
||||
}
|
||||
if total <= 0 || start >= total {
|
||||
drainReaderMediaBody(resp)
|
||||
if total > 0 {
|
||||
c.Header("Content-Range", fmt.Sprintf("bytes */%d", total))
|
||||
c.Status(http.StatusRequestedRangeNotSatisfiable)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
serveEnd := end
|
||||
if serveEnd < 0 || serveEnd >= total {
|
||||
serveEnd = total - 1
|
||||
}
|
||||
wantLen := serveEnd - start + 1
|
||||
prefixLen := int64(0)
|
||||
if partFile != nil && partLen > start {
|
||||
prefixLen = partLen - start
|
||||
if prefixLen > wantLen {
|
||||
prefixLen = wantLen
|
||||
}
|
||||
}
|
||||
|
||||
// 响应头必须一次写全:播放器据此决定后面要不要等更多字节。
|
||||
contentType := readerAudioContentType(rawURL)
|
||||
if upstreamType := strings.TrimSpace(resp.Header.Get("Content-Type")); strings.HasPrefix(strings.ToLower(upstreamType), "audio/") {
|
||||
contentType = upstreamType
|
||||
}
|
||||
writer := c.Writer
|
||||
writer.Header().Set("Content-Type", contentType)
|
||||
writer.Header().Set("Accept-Ranges", "bytes")
|
||||
writer.Header().Set("Cache-Control", "private, max-age=604800")
|
||||
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, serveEnd, total))
|
||||
writer.Header().Set("Content-Length", strconv.FormatInt(wantLen, 10))
|
||||
writer.WriteHeader(http.StatusPartialContent)
|
||||
writer.Flush()
|
||||
|
||||
written := int64(0)
|
||||
if prefixLen > 0 {
|
||||
n, copyErr := io.CopyN(writer, io.NewSectionReader(partFile, start, prefixLen), prefixLen)
|
||||
written += n
|
||||
if copyErr != nil {
|
||||
drainReaderMediaBody(resp)
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("reader 音频区间下发失败", zap.String("book", bookID), zap.Error(copyErr))
|
||||
}
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
// 前缀发完就不需要读句柄了。必须先关掉:Windows 下文件还开着时 rename
|
||||
// 会被共享冲突挡住,下面的「提升分片为正式缓存」就会静默失败。
|
||||
_ = partFile.Close()
|
||||
partFile = nil
|
||||
if written >= wantLen {
|
||||
// 客户端要的区间完全落在本地分片里:这一轮不必再回源。
|
||||
drainReaderMediaBody(resp)
|
||||
return true
|
||||
}
|
||||
|
||||
// 剩余字节边下边发;拿得到写锁时同时补进分片,落满后转成完整缓存。
|
||||
var partWriter *os.File
|
||||
if persist && partLen >= start {
|
||||
if f, size, appendErr := svc.Reader.AppendRemoteAudioPartial(bookID, rawURL); appendErr == nil {
|
||||
if size == partLen {
|
||||
partWriter = f
|
||||
} else {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
sink := io.Writer(writer)
|
||||
if partWriter != nil {
|
||||
sink = io.MultiWriter(writer, partWriter)
|
||||
}
|
||||
copied, copyErr := io.CopyBuffer(sink, io.LimitReader(resp.Body, wantLen-written), make([]byte, 32<<10))
|
||||
written += copied
|
||||
_ = resp.Body.Close()
|
||||
if partWriter != nil {
|
||||
_ = partWriter.Close()
|
||||
if copyErr == nil {
|
||||
// 分片正好落满整章时提升为正式缓存(长度对不上时内部会拒绝)。
|
||||
svc.Reader.PromoteRemoteAudioPartial(bookID, rawURL, total)
|
||||
}
|
||||
}
|
||||
if copyErr == nil && written >= wantLen {
|
||||
return true
|
||||
}
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("reader 音频区间中断",
|
||||
zap.String("book", bookID),
|
||||
zap.Int64("written", written),
|
||||
zap.Int64("want", wantLen),
|
||||
zap.Error(copyErr))
|
||||
}
|
||||
// 已经发出去的字节收不回来:只能中止响应让播放器判「不完整」,分片留给下次续传。
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
|
||||
// proxyReaderAudio 纯透传:不落盘、不排队,把上游字节原样转给播放器。
|
||||
//
|
||||
// 用在「同一条音轨已经有别的请求在写分片」时:旧实现让第二个请求排队等前一个
|
||||
// 下载结束,而首播直通会一直持锁到客户端读完整章,播放器重试因此可能干等几十秒。
|
||||
func proxyReaderAudio(
|
||||
c *gin.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
rangeHeader string,
|
||||
) {
|
||||
out, err := fetchReaderMediaResilient(c.Request.Context(), svc, book, rawURL,
|
||||
rangeHeader, headers, rangeHeader == "")
|
||||
if err != nil || out == nil || out.resp == nil {
|
||||
if svc.Log != nil && err != nil {
|
||||
svc.Log.Warn("reader 音频透传失败", zap.String("book", bookID), zap.Error(err))
|
||||
}
|
||||
c.String(http.StatusBadGateway, "音频拉取失败")
|
||||
return
|
||||
}
|
||||
resp := out.resp
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
contentType := readerAudioContentType(rawURL)
|
||||
if upstreamType := strings.TrimSpace(resp.Header.Get("Content-Type")); strings.HasPrefix(strings.ToLower(upstreamType), "audio/") {
|
||||
contentType = upstreamType
|
||||
}
|
||||
writer := c.Writer
|
||||
writer.Header().Set("Content-Type", contentType)
|
||||
writer.Header().Set("Accept-Ranges", "bytes")
|
||||
writer.Header().Set("Cache-Control", "private, max-age=604800")
|
||||
if v := resp.Header.Get("Content-Range"); v != "" {
|
||||
writer.Header().Set("Content-Range", v)
|
||||
}
|
||||
if resp.ContentLength > 0 {
|
||||
writer.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
|
||||
}
|
||||
writer.WriteHeader(resp.StatusCode)
|
||||
if len(out.head) > 0 {
|
||||
if _, writeErr := writer.Write(out.head); writeErr != nil {
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
streamReaderMediaBody(writer, resp.Body)
|
||||
}
|
||||
|
||||
// drainReaderMediaBody 丢掉剩余响应体并关闭连接(让上游把连接收回去)。
|
||||
func drainReaderMediaBody(resp *http.Response) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
// parseReaderAudioRange 解析客户端的单区间 Range 头("bytes=0-" / "bytes=100-199")。
|
||||
//
|
||||
// 多区间与后缀区间("bytes=-500" 要总长才能换算)不在这里处理:播放器不会这么
|
||||
// 发,真遇到就退回磁盘路径,由 http.ServeContent 负责。
|
||||
func parseReaderAudioRange(header string) (start, end int64, ok bool) {
|
||||
header = strings.TrimSpace(header)
|
||||
const prefix = "bytes="
|
||||
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
|
||||
return 0, 0, false
|
||||
}
|
||||
spec := strings.TrimSpace(header[len(prefix):])
|
||||
if spec == "" || strings.Contains(spec, ",") {
|
||||
return 0, 0, false
|
||||
}
|
||||
dash := strings.IndexByte(spec, '-')
|
||||
if dash <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
start, err := strconv.ParseInt(strings.TrimSpace(spec[:dash]), 10, 64)
|
||||
if err != nil || start < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
endPart := strings.TrimSpace(spec[dash+1:])
|
||||
if endPart == "" {
|
||||
return start, -1, true
|
||||
}
|
||||
end, err = strconv.ParseInt(endPart, 10, 64)
|
||||
if err != nil || end < start {
|
||||
return 0, 0, false
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// streamReaderAudioInto 首播直通:一边落盘一边把字节写给客户端(调用方已持写锁)。
|
||||
//
|
||||
// 直接写 c.Writer 而不是先落盘再 ServeContent,是为了让第一个字节尽早到达。
|
||||
// 下载失败时客户端已经收到部分字节,只能中止响应(见下面的 panic),
|
||||
// 不能改成 502 —— 状态码早就发出去了。
|
||||
func streamReaderAudioInto(
|
||||
c *gin.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
) {
|
||||
writer := c.Writer
|
||||
// started 表示响应头是否已发出。没发出时失败还能改成 502;已经发出就只能
|
||||
// 中止连接——状态码收不回来了。
|
||||
started := false
|
||||
_, err := svc.Reader.DownloadRemoteAudioStreaming(
|
||||
c.Request.Context(), bookID, rawURL, readerAudioCacheAttempts,
|
||||
readerAudioFetchFunc(c.Request.Context(), svc, book, bookID, rawURL, headers),
|
||||
func(total int64, upstreamType string) {
|
||||
contentType := readerAudioContentType(rawURL)
|
||||
// 上游给了明确类型时优先用它(个别源把 m4a 标成 audio/x-m4a)。
|
||||
if strings.HasPrefix(strings.ToLower(upstreamType), "audio/") {
|
||||
contentType = upstreamType
|
||||
}
|
||||
writer.Header().Set("Content-Type", contentType)
|
||||
writer.Header().Set("Cache-Control", "private, max-age=604800")
|
||||
if total > 0 {
|
||||
writer.Header().Set("Content-Length", strconv.FormatInt(total, 10))
|
||||
}
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
started = true
|
||||
// 立刻把已写好的头刷给客户端:gin 的 ResponseWriter 有缓冲,
|
||||
// 不 Flush 的话播放器要等到第一批音频字节才看到响应,白等一轮 RTT。
|
||||
writer.Flush()
|
||||
},
|
||||
writer,
|
||||
)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("reader 音频直通失败", zap.String("book", bookID), zap.Error(err))
|
||||
}
|
||||
if !started {
|
||||
// 一个字节都还没发出去(首拉就失败):保持与旧实现一致的错误语义。
|
||||
c.String(http.StatusBadGateway, "音频拉取失败: %s", err.Error())
|
||||
return
|
||||
}
|
||||
// 响应头已经发出:只能中止连接,让客户端判定「传输不完整」并自己重试。
|
||||
// 保留 .part 分片,下一次请求会走续传路径。
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
|
||||
// readerAudioFetchFunc 把「带换票重试的媒体拉取」适配成下载器需要的 fetcher。
|
||||
func readerAudioFetchFunc(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
) reader.AudioRangeFetcher {
|
||||
return func(rangeHeader string) (*http.Response, []byte, error) {
|
||||
// 只有整段请求才需要嗅探响应体(判 HLS/错误页);续传拿到的是 206
|
||||
// 二进制分片,读了还得再补写一次,没必要。
|
||||
out, fetchErr := fetchReaderMediaResilient(ctx, svc, book, rawURL, rangeHeader, headers, rangeHeader == "")
|
||||
if fetchErr != nil {
|
||||
if isReaderMediaNoRetry(fetchErr) {
|
||||
return nil, nil, &reader.NoRetryError{Err: fetchErr}
|
||||
}
|
||||
return nil, nil, fetchErr
|
||||
}
|
||||
return out.resp, out.head, nil
|
||||
}
|
||||
}
|
||||
|
||||
// serveCachedReaderAudio 用 http.ServeContent 下发本地音轨(自带 Range / 206 语义)。
|
||||
func serveCachedReaderAudio(c *gin.Context, f *os.File, info os.FileInfo, rawURL string) {
|
||||
c.Header("Content-Type", readerAudioContentType(rawURL))
|
||||
@@ -1082,8 +1581,9 @@ func ensureCachedRemoteAudio(
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// downloadReaderAudioToCache 整段拉取并落盘:上游断流/换票失败时重试,
|
||||
// 落盘后按 Content-Length 校验长度,防止把截断的音频缓存下来。
|
||||
// downloadReaderAudioToCache 整段拉取并落盘,支持断点续传:
|
||||
// 上游 RST/超时后用 Range 从已写入的位置接着下,不再整段重下;
|
||||
// 只有字节数与上游声明的总长一致时才会生成正式缓存文件。
|
||||
func downloadReaderAudioToCache(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
@@ -1091,76 +1591,19 @@ func downloadReaderAudioToCache(
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
) error {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= readerAudioCacheAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(readerAudioCacheRetryDelay * time.Duration(attempt-1)):
|
||||
}
|
||||
}
|
||||
out, err := fetchReaderMediaResilient(ctx, svc, book, rawURL, "", headers, true)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if out == nil || out.resp == nil {
|
||||
lastErr = fmt.Errorf("音源响应为空")
|
||||
continue
|
||||
}
|
||||
resp := out.resp
|
||||
status := resp.StatusCode
|
||||
if status != http.StatusOK && status != http.StatusPartialContent {
|
||||
_ = resp.Body.Close()
|
||||
lastErr = fmt.Errorf("音源返回 %d", status)
|
||||
continue
|
||||
}
|
||||
expected := resp.ContentLength
|
||||
path, storeErr := svc.Reader.StoreCachedRemoteAudio(bookID, rawURL, func(w io.Writer) error {
|
||||
if len(out.head) > 0 {
|
||||
if _, err := w.Write(out.head); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return copyLimited(w, resp.Body, readerAudioCacheMaxBytes)
|
||||
})
|
||||
_ = resp.Body.Close()
|
||||
if storeErr != nil {
|
||||
lastErr = storeErr
|
||||
continue
|
||||
}
|
||||
if expected > 0 {
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
_ = os.Remove(path)
|
||||
lastErr = fmt.Errorf("音频缓存写入后不可读: %w", statErr)
|
||||
continue
|
||||
}
|
||||
if info.Size() != expected {
|
||||
_ = os.Remove(path)
|
||||
lastErr = fmt.Errorf("音频写入不完整(%d/%d 字节)", info.Size(), expected)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("音源拉取失败")
|
||||
}
|
||||
return fmt.Errorf("已重试 %d 次: %w", readerAudioCacheAttempts, lastErr)
|
||||
_, err := svc.Reader.DownloadRemoteAudio(ctx, bookID, rawURL, readerAudioCacheAttempts,
|
||||
readerAudioFetchFunc(ctx, svc, book, bookID, rawURL, headers))
|
||||
return err
|
||||
}
|
||||
|
||||
// copyLimited 复制响应体,超过上限时报错(避免把超大对象写进缓存)。
|
||||
func copyLimited(w io.Writer, r io.Reader, limit int64) error {
|
||||
n, err := io.Copy(w, io.LimitReader(r, limit+1))
|
||||
if err != nil {
|
||||
return err
|
||||
// isReaderMediaNoRetry 判断这次失败是否「换个花样重试也没用」。
|
||||
// 用于让外层下载循环立刻收手,而不是把同一张被拒的票再打四遍。
|
||||
func isReaderMediaNoRetry(err error) bool {
|
||||
if errors.Is(err, errReaderMediaStaleTicket) {
|
||||
return true
|
||||
}
|
||||
if n > limit {
|
||||
return fmt.Errorf("音源超过 %d 字节上限", limit)
|
||||
}
|
||||
return nil
|
||||
var rejected *readerMediaRejectedError
|
||||
return errors.As(err, &rejected)
|
||||
}
|
||||
|
||||
// readerAudioExt 取音频地址的后缀(用于推断 Content-Type)。
|
||||
@@ -1230,6 +1673,33 @@ type readerMediaResult struct {
|
||||
// 不改变原有行为;
|
||||
// - 该换票但换不到、或换票后依然不可用 → 返回错误,由调用方给出明确文案,
|
||||
// 而不是让播放器对着必然失败的地址反复重试。
|
||||
//
|
||||
// readerMediaRejectedError 上游拒绝了这张票,继续走同一条重试路径不会有进展。
|
||||
//
|
||||
// status —— 上游状态码(401/403/410)
|
||||
// refreshed —— 这次拒绝发生在「已经换过一张新票」之后
|
||||
// message —— 对外文案(留空时按状态码生成)
|
||||
//
|
||||
// 上游的拒绝常常成批出现(几分钟一波,随后自己恢复):换票拿到的是新票,但
|
||||
// 同一时刻仍被同一个状态码挡回。连续两轮都这样时立刻停止重试——继续重试只会
|
||||
// 让播放器多等十几秒,结果并不会更好。
|
||||
type readerMediaRejectedError struct {
|
||||
status int
|
||||
refreshed bool
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *readerMediaRejectedError) Error() string {
|
||||
if e.message != "" {
|
||||
return e.message
|
||||
}
|
||||
return fmt.Sprintf("音源暂时被上游拒绝(HTTP %d),请稍后重试或换源", e.status)
|
||||
}
|
||||
|
||||
// errReaderMediaStaleTicket 换票没有拿到新票(书源还是给回同一张、或干脆给不出),
|
||||
// 再重试同一个书源不会有不同结果。具体原因由外层用 %w 串上。
|
||||
var errReaderMediaStaleTicket = errors.New("音源签名失效且重新取票失败")
|
||||
|
||||
func fetchReaderMedia(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
@@ -1257,7 +1727,10 @@ func fetchReaderMedia(
|
||||
case out.resp.StatusCode == http.StatusUnauthorized:
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
|
||||
_ = out.resp.Body.Close()
|
||||
return nil, fmt.Errorf("音源签名失效(上游 401 拒绝),请稍后重试或换源")
|
||||
return nil, &readerMediaRejectedError{
|
||||
status: http.StatusUnauthorized,
|
||||
message: "音源签名失效(上游 401 拒绝),请稍后重试或换源",
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1265,7 +1738,7 @@ func fetchReaderMedia(
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
|
||||
_ = out.resp.Body.Close()
|
||||
if refreshErr != nil {
|
||||
return nil, fmt.Errorf("音源签名失效且重新取票失败: %w", refreshErr)
|
||||
return nil, fmt.Errorf("%w: %v", errReaderMediaStaleTicket, refreshErr)
|
||||
}
|
||||
retried, _, retryErr := fetchReaderMediaOnce(ctx, svc, book, fresh, rangeHeader, options, probeBody)
|
||||
if retryErr != nil {
|
||||
@@ -1273,10 +1746,16 @@ func fetchReaderMedia(
|
||||
}
|
||||
if retried.rejected {
|
||||
_ = retried.resp.Body.Close()
|
||||
return nil, fmt.Errorf("音源换票后仍返回非媒体内容,请稍后重试或换源")
|
||||
return nil, &readerMediaRejectedError{
|
||||
status: retried.resp.StatusCode,
|
||||
refreshed: true,
|
||||
message: "音源换票后仍返回非媒体内容,请稍后重试或换源",
|
||||
}
|
||||
}
|
||||
if reader.MediaNeedsRefresh(retried.resp.StatusCode) {
|
||||
return nil, fmt.Errorf("音源换票后仍被拒绝(上游 %d),请稍后重试或换源", retried.resp.StatusCode)
|
||||
status := retried.resp.StatusCode
|
||||
_ = retried.resp.Body.Close()
|
||||
return nil, &readerMediaRejectedError{status: status, refreshed: true}
|
||||
}
|
||||
return retried, nil
|
||||
}
|
||||
@@ -1285,8 +1764,11 @@ func fetchReaderMedia(
|
||||
//
|
||||
// 跨境 CDN 判掉签名票是概率性的:换一张票往往就能取到,而旧实现只换一次,
|
||||
// 第二次失败直接把 502 交给播放器。这里最多再重来 readerMediaReissueAttempts 轮,
|
||||
// 每轮都会作废章节缓存、重新回源签票。不可恢复的 4xx(如 404)原样返回,
|
||||
// 不做无意义的等待。
|
||||
// 每轮都会作废章节缓存、重新回源签票。
|
||||
//
|
||||
// 但"没有进展"的失败会提前收手,否则用户要对着转圈干等三十多秒:
|
||||
// - 换票拿不到新票(errReaderMediaStaleTicket)→ 立即结束;
|
||||
// - 换过票仍是同一个拒绝状态码 → 两轮即结束(见 readerMediaRejectedError)。
|
||||
func fetchReaderMediaResilient(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
@@ -1296,6 +1778,7 @@ func fetchReaderMediaResilient(
|
||||
probeBody bool,
|
||||
) (*readerMediaResult, error) {
|
||||
var lastErr error
|
||||
lastRejected := 0
|
||||
for attempt := 1; attempt <= readerMediaReissueAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
@@ -1313,13 +1796,29 @@ func fetchReaderMediaResilient(
|
||||
if !out.rejected && !reader.MediaNeedsRefresh(out.resp.StatusCode) {
|
||||
return out, nil
|
||||
}
|
||||
lastErr = fmt.Errorf("音源换票后仍不可用(上游 %d)", out.resp.StatusCode)
|
||||
status := out.resp.StatusCode
|
||||
_ = out.resp.Body.Close()
|
||||
continue
|
||||
lastErr = &readerMediaRejectedError{status: status, refreshed: true}
|
||||
} else {
|
||||
lastErr = err
|
||||
if ctx.Err() != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errors.Is(err, errReaderMediaStaleTicket) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
lastErr = err
|
||||
if ctx.Err() != nil {
|
||||
return nil, err
|
||||
var rejected *readerMediaRejectedError
|
||||
if errors.As(lastErr, &rejected) {
|
||||
// 没有换票空间的拒绝:原样重试同一张票没有意义。
|
||||
if !rejected.refreshed {
|
||||
return nil, lastErr
|
||||
}
|
||||
// 换过票还是同一个状态码:上游这几分钟就是不放行。
|
||||
if lastRejected == rejected.status {
|
||||
return nil, lastErr
|
||||
}
|
||||
lastRejected = rejected.status
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
@@ -1522,7 +2021,32 @@ func readerLocalAudioHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// readerContentOptions 从查询参数解析正文下发的可选项。
|
||||
//
|
||||
// - 不传(网页端):媒体全部走服务端签名代理,与历史行为一致;
|
||||
// - `media=direct`:书源开启直连时保留上游地址,由客户端自取;
|
||||
// - `media=proxy`:本次强制代理,给客户端直连失败时的退路;
|
||||
// - `audio=native`:由客户端原生解码音频,不走本地有声书的浏览器转码。
|
||||
//
|
||||
// audio 与 media 独立,原生解码同样可以走签名代理。
|
||||
func readerContentOptions(c *gin.Context) reader.ContentOptions {
|
||||
opts := reader.ContentOptions{
|
||||
NativeAudio: strings.EqualFold(strings.TrimSpace(c.Query("audio")), "native"),
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(c.Query("media"))) {
|
||||
case "direct":
|
||||
opts.MediaDirect = true
|
||||
case "proxy":
|
||||
opts.ForceProxy = true
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。
|
||||
//
|
||||
// media=direct 是客户端(Android App)专用的可选能力:媒体地址保留上游原始
|
||||
// 地址并由客户端自行下载,服务端只下发所需请求头。不传该参数(网页端)时
|
||||
// 行为与历史版本完全一致。
|
||||
func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
chapter, err := strconv.Atoi(c.DefaultQuery("chapter", "0"))
|
||||
@@ -1531,7 +2055,8 @@ func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
content, err := svc.Reader.GetContentForBook(c.Request.Context(), userID, c.Param("id"), chapter)
|
||||
opts := readerContentOptions(c)
|
||||
content, err := svc.Reader.GetContentForBook(c.Request.Context(), userID, c.Param("id"), chapter, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -1544,13 +2069,14 @@ func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
//
|
||||
// 请求 {chapter_indexes:[...], apply_replace:bool}:单章失败不中断整批,
|
||||
// 返回每章内容与 hit/miss/failed 统计,供前端窗口预取与后续离线缓存。
|
||||
// 媒体通道同样按 media=direct 查询参数选择,默认(网页端)走服务端代理。
|
||||
func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
|
||||
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
|
||||
ApplyReplace bool `json:"apply_replace"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
|
||||
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
|
||||
ApplyReplace bool `json:"apply_replace"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter_indexes 不能为空"})
|
||||
return
|
||||
@@ -1560,7 +2086,7 @@ func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
result, err := svc.Reader.GetContentBatch(c.Request.Context(), userID, c.Param("id"), body.ChapterIndexes, body.ApplyReplace)
|
||||
result, err := svc.Reader.GetContentBatch(c.Request.Context(), userID, c.Param("id"), body.ChapterIndexes, body.ApplyReplace, readerContentOptions(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -30,10 +30,12 @@ import (
|
||||
//
|
||||
// include_media=0 只返回历史行本身。客户端判断「继续播放」指向哪一集时只
|
||||
// 需要 media_id/position_ms;带上整条 media 会让 300 条的响应从 ~40KB 涨到
|
||||
// ~420KB,手机链路上光传输就要十几秒,详情页的续播解析会被它拖住。
|
||||
// ~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
|
||||
@@ -44,19 +46,27 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
|
||||
includeMedia = parsed
|
||||
}
|
||||
}
|
||||
items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit)
|
||||
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 判断,只是决定要不要把它写进响应。
|
||||
// 可见性按 media 判断:媒体已删除/远端取不到时仍然保留这条历史。
|
||||
if item.Media == nil || visibility.Allows(item.Media) {
|
||||
if !includeMedia {
|
||||
item.Media = nil
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,3 +175,77 @@ func TestHistoryListCanOmitEmbeddedMedia(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -60,6 +60,7 @@ func AllModels() []interface{} {
|
||||
&StrmDownloadTask{},
|
||||
&StrmUploadTask{},
|
||||
&StrmDirCache{},
|
||||
&CloudDownloadTask{},
|
||||
&ScrapeTask{},
|
||||
&BackgroundTaskRecord{},
|
||||
&EmbyMount{},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,3 +364,199 @@ func ImageCachePools(imagesDir string, originalsMaxBytes int64) []ImageCachePool
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return "a, 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -199,17 +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
|
||||
}
|
||||
totalBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
|
||||
if originalsMaxBytes <= 0 {
|
||||
return PruneImageCacheResult{}, nil
|
||||
}
|
||||
pools := ImageCachePools(p.cacheDir, originalsMaxBytes)
|
||||
return PruneImageCachePools(pools, totalBytes)
|
||||
return PruneImageCachePools(pools, 0)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -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)) != ""
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -19,16 +22,32 @@ import (
|
||||
//
|
||||
// 背景:听书源的音频直链签在腾讯音乐 CDN 上,跨境链路会间歇性返回 401
|
||||
// 或直接 RST 连接。旧行为是每次请求(首播、拖动进度、播放器重试)都重新
|
||||
// 回源,一次抖动就把错误交给播放器。这里按「书 + 媒体对象路径」把整段音频
|
||||
// 落盘(忽略 ?vkey=... 这类会轮换的签名参数),命中后由 http.ServeContent
|
||||
// 回源,一次抖动就把错误交给播放器。这里按「书 + 媒体对象」把整段音频
|
||||
// 落盘(丢掉 ?vkey=... 这类会轮换的签名参数),命中后由 http.ServeContent
|
||||
// 按 Range 下发:首播成功之后不再依赖 CDN,拖动进度也是本地读取。
|
||||
|
||||
const (
|
||||
audioMediaDirName = "reader-audio"
|
||||
audioMediaTTL = 7 * 24 * time.Hour
|
||||
audioMediaMaxBytes = 2 << 30 // 2GiB:超出后按 mtime 淘汰最旧的
|
||||
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 (
|
||||
@@ -53,16 +72,48 @@ func (s *ReaderService) audioMediaDir() string {
|
||||
return filepath.Join(base, audioMediaDirName)
|
||||
}
|
||||
|
||||
// audioMediaCacheKey 缓存键:书 + scheme://host/path。
|
||||
// audioMediaVolatileParams 是换票时会变、但与「这是哪段音频」无关的 query 参数。
|
||||
// 缓存键必须忽略它们,否则每次换票都打不中缓存。
|
||||
//
|
||||
// 必须忽略 query:同一段音频每次换票都会得到新的 vkey/uid/ct,只按完整 URL
|
||||
// 取哈希的话缓存永远打不中。
|
||||
// 不在这张表里的参数一律保留:有些书源的直链形如
|
||||
// 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])
|
||||
}
|
||||
@@ -106,6 +157,23 @@ func (s *ReaderService) HasCachedRemoteAudio(bookID, mediaURL string) bool {
|
||||
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) {
|
||||
@@ -132,41 +200,400 @@ func (s *ReaderService) OpenCachedRemoteAudio(bookID, mediaURL string) (*os.File
|
||||
return f, info, true
|
||||
}
|
||||
|
||||
// StoreCachedRemoteAudio 把一段音源原子写入缓存,fill 负责把内容写进 writer。
|
||||
// 返回最终文件路径;内容为空或写入失败时不留下半成品。
|
||||
func (s *ReaderService) StoreCachedRemoteAudio(bookID, mediaURL string, fill func(io.Writer) error) (string, error) {
|
||||
// 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)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".audio-*")
|
||||
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)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
fillErr := fill(tmp)
|
||||
closeErr := tmp.Close()
|
||||
if fillErr != nil || closeErr != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
if fillErr != nil {
|
||||
return "", fillErr
|
||||
// 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)):
|
||||
}
|
||||
}
|
||||
return "", closeErr
|
||||
|
||||
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
|
||||
}
|
||||
info, statErr := os.Stat(tmpName)
|
||||
if statErr != nil || info.Size() == 0 {
|
||||
_ = os.Remove(tmpName)
|
||||
return "", errors.New("音源内容为空")
|
||||
|
||||
_ = file.Close()
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("音源拉取失败")
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return "", fmt.Errorf("写入音频缓存失败: %w", err)
|
||||
if offset > 0 {
|
||||
return "", fmt.Errorf("已重试 %d 次: %w(已缓存 %d 字节,重试可续传)", attempts, lastErr, offset)
|
||||
}
|
||||
s.pruneAudioMediaCache()
|
||||
return path, nil
|
||||
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 淘汰最旧的。
|
||||
@@ -220,3 +647,84 @@ func (s *ReaderService) pruneAudioMediaCache() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 区间直通:首播不必等整章下完 ───────────────────────────────────────────
|
||||
//
|
||||
// 播放器(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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
package reader
|
||||
|
||||
import "testing"
|
||||
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 {
|
||||
@@ -24,8 +36,8 @@ func TestIsAudioMediaURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioMediaCacheKeyIgnoresQuery 换票会改写 uid/vkey/ct,缓存键必须只看
|
||||
// scheme + host + path,否则每次换票都会重新下载一遍。
|
||||
// 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")
|
||||
@@ -39,3 +51,384 @@ func TestAudioMediaCacheKeyIgnoresQuery(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func TestRefreshMediaURLIssuesNewTicket(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
// 首读:拿到第一张票并落缓存
|
||||
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)
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestRefreshMediaURLIssuesNewTicket(t *testing.T) {
|
||||
}
|
||||
|
||||
// 缓存要被刷新成新票:再次读章节不应再回源
|
||||
out2, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
out2, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("换票后读章节失败: %v", err)
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func TestRefreshMediaURLMemoryReusesTicket(t *testing.T) {
|
||||
svc, srv, book := prepareTicketBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func TestRefreshMediaURLSkipsNonTrackURL(t *testing.T) {
|
||||
svc, _, book := prepareTicketBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if _, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0); err != nil {
|
||||
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")
|
||||
@@ -236,7 +236,7 @@ func TestRefreshMediaURLErrorsWhenSourceRepeatsTicket(t *testing.T) {
|
||||
svc, srv, book := prepareTicketBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1850,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
|
||||
@@ -1857,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),防止代理被滥用为开放中转。
|
||||
//
|
||||
@@ -1945,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)
|
||||
@@ -1962,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)
|
||||
}
|
||||
@@ -3108,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 {
|
||||
@@ -3152,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"
|
||||
@@ -3175,6 +3307,9 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
|
||||
out.Content = ""
|
||||
}
|
||||
}
|
||||
// 音频/图片地址改写:默认走签名代理(浏览器播放/展示无法带防盗链头);
|
||||
// 客户端显式请求直连且书源允许时,保留上游地址并下发所需请求头。
|
||||
s.applyMediaURLs(ctx, book, out, direct)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -3256,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 批量取正文的汇总结果。
|
||||
@@ -3278,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
|
||||
@@ -3314,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++
|
||||
@@ -3330,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
|
||||
@@ -3361,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)
|
||||
}
|
||||
|
||||
@@ -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("空正文应当报错,实际当成功下发了")
|
||||
}
|
||||
|
||||
@@ -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 表示不做任何清理)。
|
||||
// CacheCleanupPolicy 是一次全缓存容量清理要用的策略(全部为 0 表示不做任何清理)。
|
||||
// TotalBytes 是 hls / images / library-covers 三个池的总容量上限;OriginalsBytes
|
||||
// 是 images 原图池的独立子配额。reader-audio 由自身 1GB 配额管理,不在此列。
|
||||
// 只有容量配额,没有保留时长:淘汰只看占用,不按文件年龄删除。
|
||||
type ImageCachePolicy struct {
|
||||
type CacheCleanupPolicy struct {
|
||||
TotalBytes int64
|
||||
OriginalsBytes int64
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -299,30 +299,33 @@ func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
|
||||
return walkAndPrune(s.cacheDir+"/hls", cutoff)
|
||||
}
|
||||
|
||||
// jobCleanImageCache prunes the image proxy cache: 原图按独立配额优先淘汰,
|
||||
// 总量超限时再淘汰派生成品。全程只看占用,不按文件年龄删除。
|
||||
func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
|
||||
// jobCleanCache enforces the global disk-cache capacity: when the total size of
|
||||
// hls + images + library-covers exceeds the configured limit, the pools are
|
||||
// evicted strictly in that order (oldest file first within each pool) until
|
||||
// usage falls back to 80% of the limit. reader-audio is not part of the total;
|
||||
// it has its own 1 GB eviction on write. The images original sub-quota is
|
||||
// enforced afterwards as an independent constraint.
|
||||
func (s *SchedulerService) jobCleanCache(ctx context.Context) error {
|
||||
if s.cacheDir == "" {
|
||||
return nil
|
||||
}
|
||||
policy := s.imageCachePolicy()
|
||||
policy := s.cacheCleanupPolicy()
|
||||
if policy.TotalBytes <= 0 && policy.OriginalsBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
imagesDir := filepath.Join(s.cacheDir, "images")
|
||||
pools := ImageCachePools(imagesDir, policy.OriginalsBytes)
|
||||
res, err := PruneImageCachePools(pools, policy.TotalBytes)
|
||||
res, err := PruneCache(s.cacheDir, policy.TotalBytes, policy.OriginalsBytes)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("scheduled image cache cleanup failed", zap.Error(err))
|
||||
s.log.Warn("scheduled cache cleanup failed", zap.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if res.DeletedFiles > 0 && s.log != nil {
|
||||
s.log.Info("scheduled image cache cleanup completed",
|
||||
s.log.Info("scheduled cache cleanup completed",
|
||||
zap.Int("deleted_files", res.DeletedFiles),
|
||||
zap.Int64("freed_bytes", res.FreedBytes),
|
||||
zap.Int64("remaining_bytes", res.RemainingBytes),
|
||||
zap.Any("freed_by_pool", res.FreedByPool),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -46,26 +46,31 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
|
||||
return
|
||||
}
|
||||
isAdult := shouldCropAdultPoster(refreshed, lib)
|
||||
// 番号决定封套的裁剪方式:常规 DVD 取最右侧封面面板,FC2 / HEYZO / 素人等才做人脸定位。
|
||||
adultCode := ""
|
||||
if isAdult {
|
||||
adultCode = AdultCodeFromMediaPath(refreshed.Path)
|
||||
}
|
||||
artworkUpdates := map[string]any{}
|
||||
shouldPersistLocalArtworkURL := func(raw string) bool {
|
||||
return isAdult || !isHTTPish(raw)
|
||||
}
|
||||
if refreshed.PosterURL != "" {
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult); dst != "" {
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult, adultCode); dst != "" {
|
||||
if shouldPersistLocalArtworkURL(refreshed.PosterURL) {
|
||||
artworkUpdates["poster_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
}
|
||||
if refreshed.BackdropURL != "" {
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false); dst != "" {
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false, ""); dst != "" {
|
||||
if shouldPersistLocalArtworkURL(refreshed.BackdropURL) {
|
||||
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
} else if isAdult && refreshed.PosterURL != "" {
|
||||
// 番号海报原图为完整封套横图,在无独立背景图时直接作为背景图写出
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false); dst != "" {
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false, ""); dst != "" {
|
||||
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
@@ -78,7 +83,7 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
|
||||
}
|
||||
|
||||
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) string {
|
||||
return s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
|
||||
return s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false, "")
|
||||
}
|
||||
|
||||
// shouldCropAdultPoster keeps adult-cover handling independent of which
|
||||
@@ -97,8 +102,9 @@ func shouldCropAdultPoster(media *model.Media, lib *model.Library) bool {
|
||||
}
|
||||
|
||||
// downloadArtworkToPathWithOptions fetches an artwork URL via the image proxy cache and
|
||||
// writes it under dir/<name>.<ext>. For adult posters, it crops the right half of the cover.
|
||||
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) string {
|
||||
// writes it under dir/<name>.<ext>. For adult posters it crops the front-cover panel out of the
|
||||
// full-jacket cover (adultCode selects between “right-most panel” and face-aware framing).
|
||||
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool, adultCode string) string {
|
||||
var (
|
||||
data []byte
|
||||
ctype string
|
||||
@@ -132,9 +138,12 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
|
||||
ctype = detected
|
||||
}
|
||||
if cropAdultPoster {
|
||||
if cropped, croppedType, err := CropAdultCoverPoster(data); err == nil && len(cropped) > 0 {
|
||||
if cropped, croppedType, err := CropAdultCoverPosterForCode(data, adultCode); err == nil && len(cropped) > 0 {
|
||||
data = cropped
|
||||
ctype = croppedType
|
||||
// 裁剪函数在“无需裁剪”时会原样返回,类型可能为空:此时保留 sniff 出来的 MIME。
|
||||
if strings.TrimSpace(croppedType) != "" {
|
||||
ctype = croppedType
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.writeArtworkDataToPath(dir, name, ctype, data)
|
||||
@@ -191,9 +200,25 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
|
||||
return ""
|
||||
}
|
||||
s.log.Debug("scrape artwork written", zap.String("dst", dst))
|
||||
s.removeLegacyArtworkImg(dir, name, dst)
|
||||
return dst
|
||||
}
|
||||
|
||||
// removeLegacyArtworkImg 清理同名历史 "<name>.img" 文件。
|
||||
//
|
||||
// 早期版本把 image.Decode 的格式名("jpeg"/"webp")当作 MIME 写进扩展名映射,
|
||||
// 导致这些海报全部以 .img 落盘。现在写出正确扩展名后,旧 .img 只会是孤立垃圾,
|
||||
// 但 local_metadata_artwork 仍会扫到它,所以一并清掉。
|
||||
func (s *ScraperService) removeLegacyArtworkImg(dir, name, dst string) {
|
||||
legacy := filepath.Join(dir, name+".img")
|
||||
if strings.EqualFold(legacy, dst) {
|
||||
return
|
||||
}
|
||||
if err := os.Remove(legacy); err == nil {
|
||||
s.log.Debug("scrape artwork removed legacy .img", zap.String("dst", legacy))
|
||||
}
|
||||
}
|
||||
|
||||
// artworkFileUnchanged 报告 path 是否已经就是 data 这些字节。
|
||||
//
|
||||
// 刮削在刷新海报地址时会无条件重写 sidecar 图片,即使下载回来的字节一模一样。
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestDownloadArtworkMigratesLocalImgToCanonicalCroppedJPEG(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst := scraper.downloadArtworkToPathWithOptions(t.Context(), dir, "movie-poster", legacy, true)
|
||||
dst := scraper.downloadArtworkToPathWithOptions(t.Context(), dir, "movie-poster", legacy, true, "SSNI-677")
|
||||
if filepath.Base(dst) != "movie-poster.jpg" {
|
||||
t.Fatalf("destination = %q, want canonical JPEG sidecar", dst)
|
||||
}
|
||||
@@ -172,6 +172,31 @@ func TestDownloadArtworkMigratesLocalImgToCanonicalCroppedJPEG(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 已经裁好的竖版海报(例如 MetaTube 服务端裁好的 2:3 图)再次写入时不能再退化成 .img:
|
||||
// 早期版本把 image.Decode 的格式名当 MIME 用,落盘扩展名就成了 .img。
|
||||
func TestDownloadArtworkKeepsCanonicalExtensionForPortraitSource(t *testing.T) {
|
||||
scraper := &ScraperService{log: zap.NewNop()}
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "movie-poster-src.jpg")
|
||||
portrait := createTestImage(400, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
|
||||
if err := os.WriteFile(src, portrait, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟历史遗留的 .img 退格式文件。
|
||||
legacy := filepath.Join(dir, "movie-poster.img")
|
||||
if err := os.WriteFile(legacy, portrait, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst := scraper.downloadArtworkToPathWithOptions(t.Context(), dir, "movie-poster", src, true, "SSNI-677")
|
||||
if filepath.Base(dst) != "movie-poster.jpg" {
|
||||
t.Fatalf("destination = %q, want canonical .jpg (not .img)", dst)
|
||||
}
|
||||
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
|
||||
t.Fatalf("legacy .img should be cleaned up, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteMediaArtworkFilesAfterScrapeSkipsCloud verifies that cloud media
|
||||
// never has artwork written anywhere.
|
||||
func TestWriteMediaArtworkFilesAfterScrapeSkipsCloud(t *testing.T) {
|
||||
|
||||
@@ -72,6 +72,7 @@ type Container struct {
|
||||
RecognitionWords *RecognitionWordsService
|
||||
Danmaku *DanmakuService
|
||||
Strm *StrmService
|
||||
CloudDownload *CloudDownloadService
|
||||
Cloud115 *Cloud115PlaybackService
|
||||
Reader *reader.ReaderService
|
||||
Database *DatabaseAdminService
|
||||
@@ -128,6 +129,11 @@ func (c *Container) Boot() {
|
||||
c.Strm.Start(c.stopCtx)
|
||||
}
|
||||
|
||||
// 115 云下载:恢复中断的后处理阶段 + 启动后处理推进循环。
|
||||
if c.CloudDownload != nil {
|
||||
c.CloudDownload.Start(c.stopCtx)
|
||||
}
|
||||
|
||||
// 启动刮削队列后台消费者
|
||||
if c.Scraper != nil {
|
||||
c.Scraper.Start(c.stopCtx)
|
||||
@@ -178,6 +184,9 @@ func (c *Container) Close() {
|
||||
if c.Strm != nil {
|
||||
c.Strm.Stop()
|
||||
}
|
||||
if c.CloudDownload != nil {
|
||||
c.CloudDownload.Stop()
|
||||
}
|
||||
if c.Watcher != nil {
|
||||
c.Watcher.Stop()
|
||||
}
|
||||
|
||||
@@ -146,6 +146,12 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.Profile = NewProfileService(b.log, b.repos)
|
||||
b.c.Audit = NewAuditService(b.log, b.repos)
|
||||
b.c.Strm = NewStrmService(b.cfg, b.log, b.repos, b.c.Crypto)
|
||||
// 115 云下载:依赖 Strm(复用其 115 客户端与同步入口)、Scan(媒体库扫描)、
|
||||
// Scraper(自动刮削)。必须在三者都构造完成之后创建。
|
||||
b.c.CloudDownload = NewCloudDownloadService(b.log, b.repos, b.c.Strm, b.c.Scan, b.c.Scraper)
|
||||
// 同步完成 → 云下载后处理(扫描/刮削)。回调里会再调 IsSyncRunning/StartSync,
|
||||
// 所以 hook 必须在 Strm 完全就绪后注册。
|
||||
b.c.Strm.SetSyncFinishHook(b.c.CloudDownload.onStrmSyncFinished)
|
||||
b.c.Cloud115 = NewCloud115PlaybackService(b.cfg, b.log, b.repos, b.c.Strm)
|
||||
// 本地 HLS 档位的可用性取决于 ffmpeg 是否可用;未注入时按不可用处理。
|
||||
b.c.Cloud115.SetTranscoder(b.c.Transcoder)
|
||||
@@ -216,12 +222,13 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
b.c.Scheduler.SetTaskTracker(b.c.Tasks)
|
||||
b.c.Scheduler.SetBackupService(b.c.Backup)
|
||||
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
|
||||
b.c.Scheduler.SetCloudDownload(b.c.CloudDownload)
|
||||
b.c.Scheduler.SetSegments(b.c.Segments)
|
||||
b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
|
||||
b.c.Scheduler.SetCacheCleanupPolicyProvider(func() CacheCleanupPolicy {
|
||||
if b.cfg == nil {
|
||||
return ImageCachePolicy{}
|
||||
return CacheCleanupPolicy{}
|
||||
}
|
||||
policy := ImageCachePolicy{
|
||||
policy := CacheCleanupPolicy{
|
||||
TotalBytes: int64(b.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024,
|
||||
}
|
||||
if b.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 {
|
||||
|
||||
@@ -100,6 +100,12 @@ type StrmService struct {
|
||||
providerMu sync.Mutex
|
||||
provider115Cache map[string]cloud.Provider // account ID -> shared provider/OpenClient
|
||||
|
||||
// syncFinishHook 在每次同步落库后被异步调用(网盘同步完成 → 扫描/刮削等下游)。
|
||||
// 单独一把锁:不能与 mu(保护 running)耦合,否则 hook 回调里再调
|
||||
// IsSyncRunning/StartSync 会自己把自己堵住。
|
||||
hookMu sync.RWMutex
|
||||
syncFinishHook func(ctx context.Context, syncPathID, status string)
|
||||
|
||||
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
|
||||
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
|
||||
downloadSemOnce sync.Once
|
||||
@@ -503,6 +509,52 @@ func (s *StrmService) ListAccounts(ctx context.Context) ([]model.StrmAccount, er
|
||||
return s.repo.StrmAccount.List(ctx)
|
||||
}
|
||||
|
||||
// SetSyncFinishHook 注册同步完成回调。回调在同步记录落库之后异步执行,
|
||||
// 用于把「网盘文件已就绪」通知给下游(云下载后处理:扫描 → 刮削)。
|
||||
// 回调跑在独立 goroutine 里并自带 recover 与超时,绝不影响同步流程本身。
|
||||
func (s *StrmService) SetSyncFinishHook(fn func(ctx context.Context, syncPathID, status string)) {
|
||||
s.hookMu.Lock()
|
||||
s.syncFinishHook = fn
|
||||
s.hookMu.Unlock()
|
||||
}
|
||||
|
||||
// currentSyncFinishHook 读取当前回调(无回调时返回 nil)。
|
||||
func (s *StrmService) currentSyncFinishHook() func(ctx context.Context, syncPathID, status string) {
|
||||
s.hookMu.RLock()
|
||||
defer s.hookMu.RUnlock()
|
||||
return s.syncFinishHook
|
||||
}
|
||||
|
||||
// Open115Client 返回账号已缓存的 115 开放平台客户端。
|
||||
//
|
||||
// 必须复用 providerFor 的缓存实例:115 的 refresh_token 是一次性轮转的,
|
||||
// 每刷新一次旧的即作废。若调用方自建 OpenClient,两边会交替刷新、互相
|
||||
// 作废,最终把账号刷成「授权已失效」(见 newProvider 里的 OnTokenRefreshed)。
|
||||
func (s *StrmService) Open115Client(ctx context.Context, accountID string) (*cloud115.OpenClient, error) {
|
||||
acct, err := s.repo.StrmAccount.FindByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acct == nil {
|
||||
return nil, errors.New("网盘账号不存在或已删除")
|
||||
}
|
||||
if !acct.Enabled {
|
||||
return nil, errors.New("网盘账号已禁用")
|
||||
}
|
||||
if acct.Provider != model.StrmProvider115 {
|
||||
return nil, errors.New("该账号不是 115 网盘账号")
|
||||
}
|
||||
provider, err := s.providerFor(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oc, ok := provider.(cloud.OpenAPI115Provider)
|
||||
if !ok {
|
||||
return nil, errors.New("115 驱动未实现开放平台接口")
|
||||
}
|
||||
return oc.OpenClient(), nil
|
||||
}
|
||||
|
||||
// providerFor 依据账号配置构建网盘驱动。
|
||||
func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
|
||||
if acct != nil && acct.Provider == model.StrmProvider115 {
|
||||
|
||||
@@ -345,6 +345,18 @@ func (s *StrmService) finishSync(p *model.StrmSyncPath, rec *model.StrmSyncRecor
|
||||
zap.String("path_id", p.ID), zap.String("sync_type", rec.SyncType), zap.String("status", status),
|
||||
zap.Int64("new_strm", rec.NewStrm), zap.Int64("skipped", rec.Skipped), zap.Int64("new_meta", rec.NewMeta),
|
||||
zap.Int64("uploaded", rec.Uploaded), zap.Int64("pruned", rec.Pruned), zap.String("message", message))
|
||||
|
||||
// 同步结果落库之后再通知下游(云下载后处理:扫描 → 刮削)。
|
||||
// 异步 + recover + 独立超时:下游扫描可能跑几分钟,绝不能拖住同步协程收尾,
|
||||
// 也不能让它 panic 影响后续同步(helper.Go 已内建 recover)。
|
||||
if hook := s.currentSyncFinishHook(); hook != nil {
|
||||
pathID := p.ID
|
||||
helper.Go(s.log, "strm.syncFinishHook", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
hook(ctx, pathID, status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (st *strmSyncState) run() error {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// 115 云下载 API。
|
||||
import { LONG_REQUEST_TIMEOUT, api } from './client'
|
||||
import type {
|
||||
CloudDownloadList,
|
||||
CloudDownloadQuota,
|
||||
CloudDownloadSubmitResult,
|
||||
} from '../types/cloudDownload'
|
||||
|
||||
export interface CloudDownloadListParams {
|
||||
status?: string
|
||||
sync_path_id?: string
|
||||
account_id?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export const cloudDownloadAPI = {
|
||||
list: (params: CloudDownloadListParams = {}) =>
|
||||
api.get<CloudDownloadList>('/admin/cloud-downloads', { params }).then((r) => r.data),
|
||||
|
||||
/** 提交一批链接(磁力 / ed2k / http / ftp)。部分失败也在 200 的 items 里返回。 */
|
||||
submit: (payload: { sync_path_id: string; urls: string[] }) =>
|
||||
api
|
||||
.post<CloudDownloadSubmitResult>('/admin/cloud-downloads', payload, { timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
|
||||
/** 上传 .torrent 并提交为 BT 任务。 */
|
||||
submitTorrent: (file: File, syncPathID: string) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('sync_path_id', syncPathID)
|
||||
return api
|
||||
.post<CloudDownloadSubmitResult>('/admin/cloud-downloads/torrent', form, {
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
.then((r) => r.data)
|
||||
},
|
||||
|
||||
quota: (accountID: string) =>
|
||||
api
|
||||
.get<CloudDownloadQuota>('/admin/cloud-downloads/quota', { params: { account_id: accountID } })
|
||||
.then((r) => r.data),
|
||||
|
||||
retry: (id: string) => api.post(`/admin/cloud-downloads/${id}/retry`).then((r) => r.data),
|
||||
|
||||
retryPipeline: (id: string) =>
|
||||
api.post(`/admin/cloud-downloads/${id}/retry-pipeline`).then((r) => r.data),
|
||||
|
||||
cancel: (id: string) => api.post(`/admin/cloud-downloads/${id}/cancel`).then((r) => r.data),
|
||||
|
||||
remove: (id: string) => api.delete(`/admin/cloud-downloads/${id}`).then((r) => r.data),
|
||||
|
||||
clearFinished: () =>
|
||||
api.post<{ deleted: number }>('/admin/cloud-downloads/clear-finished').then((r) => r.data)
|
||||
}
|
||||
@@ -10,6 +10,18 @@ export const historyAPI = {
|
||||
.get<HistoryItem[]>('/watch-history', { params: { limit } })
|
||||
.then((r) => r.data),
|
||||
|
||||
/**
|
||||
* 只要历史行、不要内嵌的 media。
|
||||
*
|
||||
* 媒体详情页解析「继续播放」只需要 media_id/position_ms;带上 media 会让
|
||||
* 300 条响应大一个数量级,而且服务端还得为每条去查 media(挂载条目逐条
|
||||
* 回源)。服务端已经支持 include_media=0 跳过 hydrate。
|
||||
*/
|
||||
listRows: (limit = 300) =>
|
||||
api
|
||||
.get<HistoryItem[]>('/watch-history', { params: { limit, include_media: false } })
|
||||
.then((r) => r.data),
|
||||
|
||||
stats: () => api.get<HistoryStats>('/watch-history/stats').then((r) => r.data),
|
||||
|
||||
continueWatching: (limit = 10) =>
|
||||
|
||||
@@ -48,6 +48,9 @@ const StrmDownloadQueuePage = lazy(() =>
|
||||
const StrmUploadQueuePage = lazy(() =>
|
||||
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmUploadQueuePage })),
|
||||
)
|
||||
const CloudDownloadPage = lazy(() =>
|
||||
import('./pages/CloudDownloadPage').then((m) => ({ default: m.CloudDownloadPage })),
|
||||
)
|
||||
const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })))
|
||||
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
|
||||
const MediaQualityPage = lazy(() => import('./pages/MediaQualityPage').then((m) => ({ default: m.MediaQualityPage })))
|
||||
@@ -125,6 +128,7 @@ export const appRoutes: AppRoute[] = [
|
||||
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
|
||||
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
|
||||
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
|
||||
{ path: 'cloud-downloads', element: <CloudDownloadPage />, adminOnly: true },
|
||||
{ path: 'scraper/queue', element: <ScraperQueuePage />, adminOnly: true },
|
||||
{ path: 'queue', element: <TaskQueuePage />, adminOnly: true },
|
||||
{ path: 'quality', element: <MediaQualityPage />, adminOnly: true },
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
CloudDownload,
|
||||
ListChecks as SetupIcon,
|
||||
FileOutput,
|
||||
FolderOpen,
|
||||
@@ -49,6 +50,7 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
||||
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
|
||||
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
|
||||
{ to: '/cloud-downloads', label: '云下载', icon: CloudDownload, adminOnly: true },
|
||||
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
isJumpIntentKey,
|
||||
isScrollIntentKey,
|
||||
shouldReapplyGuardedScroll,
|
||||
shouldPersistClampedSample,
|
||||
shouldPersistScrollSample,
|
||||
shouldRememberScroll,
|
||||
@@ -100,4 +101,62 @@ check('home allows a long jump', isJumpIntentKey('Home'))
|
||||
check('arrow key keeps smooth-only intent', !isJumpIntentKey('ArrowDown'))
|
||||
check('space keeps smooth-only intent', !isJumpIntentKey(' '))
|
||||
|
||||
// 恢复完成后的看守期:列表重排(普通网格换成虚拟列表)会把容器高度压塌,
|
||||
// scrollTop 被浏览器夹小;没有用户滚动意图时要把记忆位置贴回去。
|
||||
check(
|
||||
'collapsed position inside the guard window is re-applied',
|
||||
shouldReapplyGuardedScroll({
|
||||
current: 0,
|
||||
target: 2600,
|
||||
maxScroll: 30000,
|
||||
userDriven: false,
|
||||
now: 1000,
|
||||
guardUntil: 3000,
|
||||
}),
|
||||
)
|
||||
check(
|
||||
'user scroll during the guard window wins',
|
||||
!shouldReapplyGuardedScroll({
|
||||
current: 0,
|
||||
target: 2600,
|
||||
maxScroll: 30000,
|
||||
userDriven: true,
|
||||
now: 1000,
|
||||
guardUntil: 3000,
|
||||
}),
|
||||
)
|
||||
check(
|
||||
'no re-apply after the guard window',
|
||||
!shouldReapplyGuardedScroll({
|
||||
current: 0,
|
||||
target: 2600,
|
||||
maxScroll: 30000,
|
||||
userDriven: false,
|
||||
now: 4000,
|
||||
guardUntil: 3000,
|
||||
}),
|
||||
)
|
||||
check(
|
||||
'no re-apply while the content is still too short',
|
||||
!shouldReapplyGuardedScroll({
|
||||
current: 0,
|
||||
target: 0,
|
||||
maxScroll: 0,
|
||||
userDriven: false,
|
||||
now: 1000,
|
||||
guardUntil: 3000,
|
||||
}),
|
||||
)
|
||||
check(
|
||||
'position already at the target is left alone',
|
||||
!shouldReapplyGuardedScroll({
|
||||
current: 2600,
|
||||
target: 2600,
|
||||
maxScroll: 30000,
|
||||
userDriven: false,
|
||||
now: 1000,
|
||||
guardUntil: 3000,
|
||||
}),
|
||||
)
|
||||
|
||||
console.log('useScrollMemory.test.ts ok')
|
||||
|
||||
@@ -109,6 +109,35 @@ export function shouldPersistClampedSample(input: {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复完成后的看守时长:列表在这一小段时间里仍可能重排(分页补页让普通网格
|
||||
* 切换成虚拟列表、海报量高……),内容高度瞬间被压塌时浏览器会把 scrollTop
|
||||
* 夹小,表现就是“返回列表后跳到顶部”。这段时间内没有用户滚动意图就把记忆
|
||||
* 位置贴回去。
|
||||
*/
|
||||
const RESTORE_GUARD_MS = 3000
|
||||
|
||||
/**
|
||||
* 看守期里哪些 scrollTop 跌落要贴回记忆位置。
|
||||
*
|
||||
* 只处理“内容还够长、用户没有滚动意图、位置却掉到记忆位置上方”的跌落;
|
||||
* 用户自己滚上去(有滚动意图)不干预,内容还没长回原来的高度也不强行回填。
|
||||
*/
|
||||
export function shouldReapplyGuardedScroll(input: {
|
||||
current: number
|
||||
target: number
|
||||
maxScroll: number
|
||||
userDriven: boolean
|
||||
now: number
|
||||
guardUntil: number
|
||||
}): boolean {
|
||||
const { current, target, maxScroll, userDriven, now, guardUntil } = input
|
||||
if (now > guardUntil) return false
|
||||
if (userDriven) return false
|
||||
if (maxScroll <= 0 || target <= 0) return false
|
||||
return current < target - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住列表页的滚动位置。页面内容会异步长高,因此恢复期间会监听内容高度,
|
||||
* 直到目标位置可达;期间用户主动滚动会立即接管,避免和恢复逻辑抢滚动条。
|
||||
@@ -141,8 +170,15 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
|
||||
let restoreTimer: number | null = null
|
||||
let restoreFrame = 0
|
||||
let restorePumpUntil = 0
|
||||
// 恢复完成后的看守截止时间:见 RESTORE_GUARD_MS。
|
||||
let guardUntil = 0
|
||||
// 看守期里被“用户正在滚动”挡下的那次回填,等滚动意图过期后补做一次。
|
||||
let guardRetryTimer: number | null = null
|
||||
let lastSaved = saved
|
||||
let lastHeight = el.scrollHeight
|
||||
// 内容容器(`<main>` 里那层宽度约束 div):分页补页、列表切换渲染方式都会
|
||||
// 改变它的高度,恢复回填与看守都靠它驱动。
|
||||
const content = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : null
|
||||
// 最近一次“用户主动滚动”的时间戳与类型(滚轮 / 触摸 / 翻页键 / 拖滚动条)。
|
||||
// 只有用户自己滚出来的位置才写回存储:恢复逻辑、虚拟列表挂载量高、浏览器
|
||||
// 钳制造成的 scrollTop 变化都不代表用户想要的位置,写进去就会把记忆抹掉。
|
||||
@@ -183,25 +219,91 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
|
||||
window.clearTimeout(restoreTimer)
|
||||
restoreTimer = null
|
||||
}
|
||||
if (guardRetryTimer != null) {
|
||||
window.clearTimeout(guardRetryTimer)
|
||||
guardRetryTimer = null
|
||||
}
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
guardUntil = 0
|
||||
}
|
||||
|
||||
/** 恢复完成后继续看守一小段时间,见 RESTORE_GUARD_MS。 */
|
||||
const startGuard = (target: number) => {
|
||||
if (target <= 0) return
|
||||
guardUntil = performance.now() + RESTORE_GUARD_MS
|
||||
if (restoreFrame) {
|
||||
window.cancelAnimationFrame(restoreFrame)
|
||||
restoreFrame = 0
|
||||
}
|
||||
if (restoreTimer != null) window.clearTimeout(restoreTimer)
|
||||
restoreTimer = window.setTimeout(() => {
|
||||
restoreTimer = null
|
||||
stopRestore()
|
||||
}, RESTORE_GUARD_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 看守期里的塌陷回填:列表重排(普通网格换成虚拟列表、海报重新量高)会让
|
||||
* 内容瞬间变矮,浏览器随之把 scrollTop 夹小,表现就是“返回列表后跳到顶部”。
|
||||
* 用户没有滚动意图时把记忆位置贴回去。
|
||||
*/
|
||||
const reapplyGuardedScroll = () => {
|
||||
if (restoring) return
|
||||
const max = currentMaxScroll()
|
||||
const target = Math.min(lastSaved, max)
|
||||
if (
|
||||
shouldReapplyGuardedScroll({
|
||||
current: el.scrollTop,
|
||||
target,
|
||||
maxScroll: max,
|
||||
userDriven: userScrolling(),
|
||||
now: performance.now(),
|
||||
guardUntil,
|
||||
})
|
||||
) {
|
||||
el.scrollTop = target
|
||||
return
|
||||
}
|
||||
// 这次塌陷正好撞上用户还在滚动:等滚动意图过期后再补做一次,别把它漏掉
|
||||
// (否则用户滚完停手,列表就停在被夹出来的顶部不动了)。
|
||||
if (userScrolling() && performance.now() <= guardUntil && guardRetryTimer == null) {
|
||||
guardRetryTimer = window.setTimeout(() => {
|
||||
guardRetryTimer = null
|
||||
reapplyGuardedScroll()
|
||||
}, USER_SCROLL_INTENT_TTL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const onContentSizeChange = () => {
|
||||
if (restoring) {
|
||||
tryRestore()
|
||||
return
|
||||
}
|
||||
reapplyGuardedScroll()
|
||||
}
|
||||
|
||||
const finishRestore = () => {
|
||||
if (!restoring) return
|
||||
restoring = false
|
||||
stopRestore()
|
||||
const current = Math.round(el.scrollTop)
|
||||
if (!sampleIsTrustworthy(current)) {
|
||||
// 目标一直不可达(内容仍比记忆位置矮 / 还在加载):保留原记忆值,
|
||||
// 不要把钳制出来的位置写回去。
|
||||
// 恢复了半天位置却掉到记忆位置上方很远,而且不是用户自己滚上去的:
|
||||
// 这是浏览器把 scrollTop 夹出来的值(内容被压塌、还在加载占位)。
|
||||
// 记忆值必须保留,否则下次进来就真的从顶部开始了。
|
||||
const droppedFar =
|
||||
saved - current > maxSmoothJumpUp(el.clientHeight) && !userScrolling()
|
||||
if (droppedFar || !sampleIsTrustworthy(current)) {
|
||||
stopRestore()
|
||||
return
|
||||
}
|
||||
adoptScrollPosition(current)
|
||||
startGuard(current)
|
||||
}
|
||||
|
||||
const tryRestore = () => {
|
||||
if (!restoring) return
|
||||
// 用户正在滚动:本帧不要回填,把控制权交给他(交给 saveNow 的接管分支)。
|
||||
if (userScrolling()) return
|
||||
const max = currentMaxScroll()
|
||||
const target = Math.min(saved, max)
|
||||
if (target > 0 && Math.abs(el.scrollTop - target) > 1) {
|
||||
@@ -230,19 +332,20 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
|
||||
// 内容还没长回来时读到的 scrollTop 不是用户选的位置,既不能写存储,
|
||||
// 也不能当成“用户接管”。
|
||||
if (!sampleIsTrustworthy(current)) return
|
||||
// 一瞬间大幅向上跳(恢复回填、虚拟列表挂载量高、浏览器钳制)只有按键翻页 /
|
||||
// 拖滚动条这类意图才可能是用户行为,否则直接丢弃。
|
||||
const upJump = lastSaved > current ? lastSaved - current : 0
|
||||
if (upJump > maxSmoothJumpUp(el.clientHeight) && !(userDriven && userScrollAllowsJump)) {
|
||||
if (restoring) {
|
||||
// 恢复途中用户自己滚了:一律以用户为准(哪怕一次跳得很远,也是他真实的
|
||||
// 滚轮 / 触摸 / 翻页操作)。位置仍要看住:列表随后切换渲染方式时容器
|
||||
// 高度还是会塌,塌了要贴回“用户自己选的位置”。
|
||||
if (!userDriven || Math.abs(current - saved) <= 1) return
|
||||
restoring = false
|
||||
adoptScrollPosition(current)
|
||||
startGuard(current)
|
||||
return
|
||||
}
|
||||
if (restoring) {
|
||||
if (!userDriven || Math.abs(current - saved) <= 1) return
|
||||
// 恢复途中用户真的滚到了别处:交还控制权并记录这个位置,避免恢复
|
||||
// 逻辑继续和用户抢滚动条。
|
||||
restoring = false
|
||||
stopRestore()
|
||||
adoptScrollPosition(current)
|
||||
// 非恢复状态下,一瞬间大幅向上跳(虚拟列表挂载量高、浏览器钳制)只有按键
|
||||
// 翻页 / 拖滚动条这类意图才可能是用户行为,否则直接丢弃。
|
||||
const upJump = lastSaved > current ? lastSaved - current : 0
|
||||
if (upJump > maxSmoothJumpUp(el.clientHeight) && !(userDriven && userScrollAllowsJump)) {
|
||||
return
|
||||
}
|
||||
// 非用户滚动(恢复逻辑回填、虚拟列表挂载/量高、浏览器钳制)不写存储。
|
||||
@@ -276,13 +379,12 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
|
||||
if (saved <= 0) {
|
||||
el.scrollTop = 0
|
||||
} else {
|
||||
if (typeof ResizeObserver !== 'undefined' && content) {
|
||||
observer = new ResizeObserver(onContentSizeChange)
|
||||
observer.observe(content)
|
||||
}
|
||||
tryRestore()
|
||||
if (restoring) {
|
||||
const content = el.firstElementChild
|
||||
if (typeof ResizeObserver !== 'undefined' && content instanceof HTMLElement) {
|
||||
observer = new ResizeObserver(() => tryRestore())
|
||||
observer.observe(content)
|
||||
}
|
||||
restorePumpUntil = performance.now() + 1000
|
||||
restoreFrame = window.requestAnimationFrame(pumpRestore)
|
||||
restoreTimer = window.setTimeout(finishRestore, 10000)
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
// 115 云下载页面:提交磁力/ed2k/HTTP/.torrent → 115 离线下载 → 自动 STRM 同步 →
|
||||
// 自动扫描 → 自动刮削。链路各阶段在任务列表里可见。
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
CloudDownload,
|
||||
FileUp,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { cloudDownloadAPI } from '../api/cloudDownload'
|
||||
import { strmAPI } from '../api/strm'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import type { StrmSyncPath } from '../types/strm'
|
||||
import type {
|
||||
CloudDownloadQuota,
|
||||
CloudDownloadSubmitResult,
|
||||
CloudDownloadTask,
|
||||
} from '../types/cloudDownload'
|
||||
import { apiErrorMessage, formatBytes, formatTime } from './StrmManagePage'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
// 阶段与状态合并成一句人话,避免用户对着 status/stage 两个字段猜。
|
||||
function taskPhase(task: CloudDownloadTask): { label: string; cls: string; spinner?: boolean } {
|
||||
if (task.status === 'failed') return { label: '下载失败', cls: 'bg-rose-100 text-rose-600' }
|
||||
if (task.status === 'canceled') return { label: '已取消', cls: 'bg-gray-100 text-gray-500' }
|
||||
if (task.status === 'queued' || task.status === 'submitted') {
|
||||
return { label: '等待 115 受理', cls: 'bg-amber-100 text-amber-600', spinner: true }
|
||||
}
|
||||
if (task.status === 'downloading') {
|
||||
return { label: `下载中 ${task.percent ?? 0}%`, cls: 'bg-brand-100 text-brand-600', spinner: true }
|
||||
}
|
||||
// status = succeeded:看后处理阶段
|
||||
switch (task.stage) {
|
||||
case 'waiting_sync':
|
||||
return { label: '等待同步', cls: 'bg-sky-100 text-sky-600', spinner: true }
|
||||
case 'syncing':
|
||||
return { label: 'STRM 同步中', cls: 'bg-sky-100 text-sky-600', spinner: true }
|
||||
case 'scanning':
|
||||
return { label: '扫描媒体库', cls: 'bg-indigo-100 text-indigo-600', spinner: true }
|
||||
case 'scraping':
|
||||
return { label: '刮削中', cls: 'bg-violet-100 text-violet-600', spinner: true }
|
||||
case 'done':
|
||||
return { label: '完成', cls: 'bg-emerald-100 text-emerald-600' }
|
||||
case 'failed':
|
||||
return { label: '后处理失败', cls: 'bg-rose-100 text-rose-600' }
|
||||
case 'na':
|
||||
return { label: '未接入媒体库', cls: 'bg-amber-100 text-amber-600' }
|
||||
default:
|
||||
return { label: '待后处理', cls: 'bg-sky-100 text-sky-600', spinner: true }
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(task: CloudDownloadTask): string {
|
||||
if (task.source_type === 'torrent') return task.torrent_name || task.source_url || '.torrent'
|
||||
return task.source_url
|
||||
}
|
||||
|
||||
export function CloudDownloadPage() {
|
||||
const [paths, setPaths] = useState<StrmSyncPath[]>([])
|
||||
const [syncPathID, setSyncPathID] = useState('')
|
||||
const [urls, setUrls] = useState('')
|
||||
const [torrentFile, setTorrentFile] = useState<File | null>(null)
|
||||
const [quota, setQuota] = useState<CloudDownloadQuota | null>(null)
|
||||
const [quotaError, setQuotaError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [lastResult, setLastResult] = useState<CloudDownloadSubmitResult | null>(null)
|
||||
|
||||
const [tasks, setTasks] = useState<CloudDownloadTask[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [busyID, setBusyID] = useState('')
|
||||
const refreshSeq = useRef(0)
|
||||
|
||||
const targets = useMemo(
|
||||
() => paths.filter((p) => p.provider === 'cloud115' && p.enabled),
|
||||
[paths],
|
||||
)
|
||||
const selectedPath = useMemo(
|
||||
() => targets.find((p) => p.id === syncPathID) ?? null,
|
||||
[targets, syncPathID],
|
||||
)
|
||||
|
||||
const loadPaths = useCallback(async () => {
|
||||
try {
|
||||
const list = await strmAPI.listPaths()
|
||||
setPaths(list)
|
||||
setSyncPathID((prev) => {
|
||||
if (prev && list.some((p) => p.id === prev)) return prev
|
||||
const first = list.find((p) => p.provider === 'cloud115' && p.enabled)
|
||||
return first?.id ?? ''
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadTasks = useCallback(
|
||||
async (quiet = false) => {
|
||||
const seq = ++refreshSeq.current
|
||||
if (!quiet) setLoading(true)
|
||||
try {
|
||||
const data = await cloudDownloadAPI.list({
|
||||
status: statusFilter || undefined,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
})
|
||||
if (seq !== refreshSeq.current) return
|
||||
setTasks(data.items ?? [])
|
||||
setTotal(data.total ?? 0)
|
||||
} catch (err) {
|
||||
if (!quiet) toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
if (seq === refreshSeq.current) setLoading(false)
|
||||
}
|
||||
},
|
||||
[page, statusFilter],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadPaths()
|
||||
}, [loadPaths])
|
||||
|
||||
useEffect(() => {
|
||||
void loadTasks()
|
||||
}, [loadTasks])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTasks(true)
|
||||
}, 5000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [autoRefresh, loadTasks])
|
||||
|
||||
// 切换目标目录时刷新配额(配额按账号统计)。
|
||||
useEffect(() => {
|
||||
setQuota(null)
|
||||
setQuotaError('')
|
||||
if (!selectedPath?.account_id) return
|
||||
let cancelled = false
|
||||
cloudDownloadAPI
|
||||
.quota(selectedPath.account_id)
|
||||
.then((q) => {
|
||||
if (!cancelled) setQuota(q)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setQuotaError(apiErrorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedPath?.account_id])
|
||||
|
||||
const submitLinks = async () => {
|
||||
if (!syncPathID) {
|
||||
toast.error('请选择目标同步目录')
|
||||
return
|
||||
}
|
||||
const lines = urls
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
if (lines.length === 0) {
|
||||
toast.error('请粘贴至少一条链接')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await cloudDownloadAPI.submit({ sync_path_id: syncPathID, urls: lines })
|
||||
setLastResult(res)
|
||||
if (res.accepted > 0) {
|
||||
toast.success(`已提交 ${res.accepted} 条任务`)
|
||||
setUrls('')
|
||||
} else {
|
||||
toast.error('没有任务被 115 受理,请查看下方明细')
|
||||
}
|
||||
await loadTasks(true)
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitTorrent = async () => {
|
||||
if (!syncPathID) {
|
||||
toast.error('请选择目标同步目录')
|
||||
return
|
||||
}
|
||||
if (!torrentFile) {
|
||||
toast.error('请选择 .torrent 文件')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await cloudDownloadAPI.submitTorrent(torrentFile, syncPathID)
|
||||
setLastResult(res)
|
||||
toast.success('种子已提交')
|
||||
setTorrentFile(null)
|
||||
await loadTasks(true)
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const act = async (id: string, fn: () => Promise<unknown>, okText: string) => {
|
||||
setBusyID(id)
|
||||
try {
|
||||
await fn()
|
||||
toast.success(okText)
|
||||
await loadTasks(true)
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setBusyID('')
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6 px-4 py-6">
|
||||
<PageHeader
|
||||
title="云下载"
|
||||
description="提交磁力 / ed2k / 直链或 .torrent,115 离线完成后自动同步 STRM、扫描媒体库并刮削"
|
||||
backTo="/strm"
|
||||
backLabel="STRM 管理"
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadTasks()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-ink-600 hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 提交面板 */}
|
||||
<section className="space-y-4 rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-ink-50">目标同步目录(115)</span>
|
||||
<select
|
||||
value={syncPathID}
|
||||
onChange={(e) => setSyncPathID(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">请选择…</option>
|
||||
{targets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.remote_display_path ? ` · ${p.remote_display_path}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-ink-50">云下载配额</span>
|
||||
<div className="flex h-[38px] items-center gap-2 rounded-lg border border-gray-100 bg-gray-50 px-3 text-sm text-ink-600">
|
||||
{quota ? (
|
||||
<>
|
||||
<span className="font-semibold text-ink-600">{quota.surplus}</span>
|
||||
<span className="text-ink-50">/ {quota.count} 剩余</span>
|
||||
{quota.surplus <= 0 ? (
|
||||
<span className="ml-auto text-xs text-rose-500">配额已用尽</span>
|
||||
) : null}
|
||||
</>
|
||||
) : quotaError ? (
|
||||
<span className="truncate text-xs text-amber-600">{quotaError}</span>
|
||||
) : (
|
||||
<span className="text-xs text-ink-50">选择目录后加载</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-ink-50">
|
||||
链接(每行一条,支持磁力 / ed2k / http(s) / ftp,单批最多 115 条)
|
||||
</span>
|
||||
<textarea
|
||||
value={urls}
|
||||
onChange={(e) => setUrls(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={'magnet:?xt=urn:btih:…\ned2k://|file|…\nhttps://example.com/file.mkv'}
|
||||
className="w-full resize-y rounded-lg border border-gray-200 px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting || !syncPathID}
|
||||
onClick={() => void submitLinks()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-500 px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />}
|
||||
提交链接
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-ink-50">或</span>
|
||||
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-2 text-sm text-ink-600 hover:bg-gray-50">
|
||||
<FileUp className="h-4 w-4" />
|
||||
{torrentFile ? torrentFile.name : '选择 .torrent'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".torrent"
|
||||
className="hidden"
|
||||
onChange={(e) => setTorrentFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting || !syncPathID || !torrentFile}
|
||||
onClick={() => void submitTorrent()}
|
||||
className="rounded-lg border border-brand-200 px-3 py-2 text-sm text-brand-600 disabled:opacity-40"
|
||||
>
|
||||
上传种子
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-ink-50">
|
||||
说明:115 开放平台的磁力链路不支持选择部分文件,磁力任务会整包下载;.torrent 目前也按全选提交。
|
||||
</p>
|
||||
|
||||
{lastResult ? (
|
||||
<div className="space-y-1 rounded-lg bg-gray-50 p-3 text-xs">
|
||||
<div className="font-medium text-ink-600">
|
||||
成功 {lastResult.accepted} 条,失败 {lastResult.rejected} 条
|
||||
</div>
|
||||
{lastResult.items?.map((item, idx) => (
|
||||
<div key={`${item.source}-${idx}`} className="flex gap-2">
|
||||
{item.ok ? (
|
||||
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-rose-500" />
|
||||
)}
|
||||
<span className="break-all text-ink-600">{item.source}</span>
|
||||
{item.error ? <span className="text-rose-500">{item.error}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
{lastResult.skipped?.map((item, idx) => (
|
||||
<div key={`skip-${idx}`} className="flex gap-2 text-ink-50">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<span className="break-all">{item.raw}</span>
|
||||
<span>{item.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<section className="space-y-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="rounded-lg border border-gray-200 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="submitted">已提交</option>
|
||||
<option value="downloading">下载中</option>
|
||||
<option value="succeeded">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="canceled">已取消</option>
|
||||
</select>
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-ink-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
/>
|
||||
自动刷新
|
||||
</label>
|
||||
<span className="ml-auto text-xs text-ink-50">共 {total} 条</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void act('__clear__', () => cloudDownloadAPI.clearFinished(), '已清空失败/取消任务')
|
||||
}
|
||||
className="rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs text-ink-600 hover:bg-gray-50"
|
||||
>
|
||||
清空失败/已取消
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[860px] text-left text-sm">
|
||||
<thead className="text-xs text-ink-50">
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="py-2 pr-3 font-medium">来源</th>
|
||||
<th className="py-2 pr-3 font-medium">目标目录</th>
|
||||
<th className="py-2 pr-3 font-medium">大小</th>
|
||||
<th className="py-2 pr-3 font-medium">状态</th>
|
||||
<th className="py-2 pr-3 font-medium">提交时间</th>
|
||||
<th className="py-2 pr-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-8 text-center text-sm text-ink-50">
|
||||
{loading ? '加载中…' : '暂无任务'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tasks.map((task) => {
|
||||
const phase = taskPhase(task)
|
||||
const busy = busyID === task.id
|
||||
const terminal =
|
||||
task.status === 'succeeded' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled'
|
||||
return (
|
||||
<tr key={task.id} className="border-b border-gray-50 align-top">
|
||||
<td className="max-w-[320px] py-2.5 pr-3">
|
||||
<div className="truncate text-ink-600" title={sourceLabel(task)}>
|
||||
{sourceLabel(task)}
|
||||
</div>
|
||||
<div className="text-xs text-ink-50">
|
||||
{task.source_type.toUpperCase()}
|
||||
{task.info_hash ? ` · ${task.info_hash.slice(0, 12)}…` : ''}
|
||||
</div>
|
||||
{task.error ? (
|
||||
<div className="mt-0.5 text-xs text-rose-500">{task.error}</div>
|
||||
) : null}
|
||||
{task.stage_error ? (
|
||||
<div className="mt-0.5 text-xs text-amber-600">{task.stage_error}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-xs text-ink-600">
|
||||
<div>{task.sync_path_name || task.sync_path_id.slice(0, 8)}</div>
|
||||
{task.remote_display_path ? (
|
||||
<div className="text-ink-50">{task.remote_display_path}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-xs text-ink-600">
|
||||
{task.size_bytes ? formatBytes(task.size_bytes) : '-'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs ${phase.cls}`}
|
||||
>
|
||||
{phase.spinner ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||||
{phase.label}
|
||||
</span>
|
||||
{task.stage === 'done' ? (
|
||||
<div className="mt-0.5 text-[11px] text-ink-50">
|
||||
新增 {task.scan_added} · 刮削 {task.scrape_queued}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-xs text-ink-50">{formatTime(task.created_at)}</td>
|
||||
<td className="py-2.5 pr-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{task.status === 'failed' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(task.id, () => cloudDownloadAPI.retry(task.id), '已重新提交')
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-ink-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
{(task.stage === 'failed' || task.stage === 'na') ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(
|
||||
task.id,
|
||||
() => cloudDownloadAPI.retryPipeline(task.id),
|
||||
'已重新推进后处理',
|
||||
)
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-ink-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
重做后处理
|
||||
</button>
|
||||
) : null}
|
||||
{!terminal ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(task.id, () => cloudDownloadAPI.cancel(task.id), '已取消')
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-amber-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(task.id, () => cloudDownloadAPI.remove(task.id), '已删除')
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-xs text-rose-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<div className="flex items-center justify-end gap-2 text-xs text-ink-50">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
className="rounded-md border border-gray-200 px-2 py-1 disabled:opacity-40"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
className="rounded-md border border-gray-200 px-2 py-1 disabled:opacity-40"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,10 +8,15 @@ import type { Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
// 超过该数量后切换为虚拟滚动:DOM 只挂载视口内的卡片,大库不再线性劣化。
|
||||
// 判定必须用“库的总量”而不是“已加载条数”:分页补页会把已加载条数推过阈值,
|
||||
// 若此时才切换渲染方式,整块网格的 DOM 子树会被替换、容器高度瞬间塌陷,
|
||||
// 浏览器随之把 scrollTop 夹回顶部,滚动记忆恢复得再好也会当场失效。
|
||||
const VIRTUALIZE_THRESHOLD = 200
|
||||
|
||||
type LibraryMediaSectionsProps = {
|
||||
isSeries: boolean
|
||||
/** 当前列表的总条数(媒体库总量),用于稳定地决定是否走虚拟滚动。 */
|
||||
total: number
|
||||
items: Media[]
|
||||
seriesCards: SeriesCard[]
|
||||
selectedSeries: SeriesCard | null
|
||||
@@ -25,6 +30,7 @@ type LibraryMediaSectionsProps = {
|
||||
|
||||
export function LibraryMediaSections({
|
||||
isSeries,
|
||||
total,
|
||||
items,
|
||||
seriesCards,
|
||||
selectedSeries,
|
||||
@@ -35,15 +41,22 @@ export function LibraryMediaSections({
|
||||
cardActions,
|
||||
onSeriesClick,
|
||||
}: LibraryMediaSectionsProps) {
|
||||
// 渲染方式在整次浏览里保持不变:总量超过阈值就一开始就用虚拟列表。
|
||||
const virtualizeByTotal = total > VIRTUALIZE_THRESHOLD
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isSeries && items.length > 0 && (
|
||||
<MediaGrid count={items.length} renderItem={(index) => {
|
||||
const media = items[index]
|
||||
// renderActions 传函数引用:MediaCard 重渲染时才构建操作按钮,
|
||||
// 配合 memo,父级无关状态变化不再级联重渲染全部卡片。
|
||||
return <MediaCard key={media.id} media={media} renderActions={cardActions} />
|
||||
}} />
|
||||
<MediaGrid
|
||||
count={items.length}
|
||||
virtualize={virtualizeByTotal}
|
||||
renderItem={(index) => {
|
||||
const media = items[index]
|
||||
// renderActions 传函数引用:MediaCard 重渲染时才构建操作按钮,
|
||||
// 配合 memo,父级无关状态变化不再级联重渲染全部卡片。
|
||||
return <MediaCard key={media.id} media={media} renderActions={cardActions} />
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isSeries && items.length === 0 && (
|
||||
@@ -51,17 +64,21 @@ export function LibraryMediaSections({
|
||||
)}
|
||||
|
||||
{isSeries && seriesCards.length > 0 && !selectedSeries && (
|
||||
<MediaGrid count={seriesCards.length} renderItem={(index) => {
|
||||
const series = seriesCards[index]
|
||||
return (
|
||||
<SeriesCardItem
|
||||
key={series.key}
|
||||
series={series}
|
||||
cardActions={cardActions}
|
||||
onSeriesClick={onSeriesClick}
|
||||
/>
|
||||
)
|
||||
}} />
|
||||
<MediaGrid
|
||||
count={seriesCards.length}
|
||||
virtualize={virtualizeByTotal}
|
||||
renderItem={(index) => {
|
||||
const series = seriesCards[index]
|
||||
return (
|
||||
<SeriesCardItem
|
||||
key={series.key}
|
||||
series={series}
|
||||
cardActions={cardActions}
|
||||
onSeriesClick={onSeriesClick}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSeries && seriesCards.length === 0 && !loading && (
|
||||
@@ -94,8 +111,16 @@ const SeriesCardItem = memo(function SeriesCardItem({
|
||||
)
|
||||
})
|
||||
|
||||
function MediaGrid({ count, renderItem }: { count: number; renderItem: (index: number) => ReactNode }) {
|
||||
if (count <= VIRTUALIZE_THRESHOLD) {
|
||||
function MediaGrid({
|
||||
count,
|
||||
virtualize,
|
||||
renderItem,
|
||||
}: {
|
||||
count: number
|
||||
virtualize: boolean
|
||||
renderItem: (index: number) => ReactNode
|
||||
}) {
|
||||
if (!virtualize) {
|
||||
return <div className={MEDIA_GRID_CLASS}>{Array.from({ length: count }, (_, index) => renderItem(index))}</div>
|
||||
}
|
||||
return <VirtualMediaGrid totalCount={count} renderItem={renderItem} />
|
||||
|
||||
@@ -29,6 +29,7 @@ import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
|
||||
import { useLibraryData } from './useLibraryData'
|
||||
import { useLibraryScanStatus } from './useLibraryScanStatus'
|
||||
import { useLibrarySeriesSelection } from './useLibrarySeriesSelection'
|
||||
import { useSeriesEpisodeResume } from './useSeriesEpisodeResume'
|
||||
import { useLibraryAdminActions } from './useLibraryAdminActions'
|
||||
import { usePermission } from '../hooks/usePermission'
|
||||
import { useFavourites } from '../hooks/useFavourites'
|
||||
@@ -214,6 +215,13 @@ export function LibraryPage() {
|
||||
setSelectedSeason,
|
||||
onClearSeriesState: () => setSeriesMetadataEditOpen(false),
|
||||
})
|
||||
// 抽屉里的「继续播放」:在整剧所有集里挑最近看过的一条(代表条目可能是 OVA)。
|
||||
const { resume: seriesResume, progressById: seriesEpisodeProgress } = useSeriesEpisodeResume(
|
||||
selectedSeries?.key ?? '',
|
||||
selectedSeries?.rep ?? null,
|
||||
selectedSeriesAllEpisodes,
|
||||
)
|
||||
|
||||
const selectedSeriesScrapeMedia = selectedSeriesEpisodes.find((media) => !isTheatricalFeature(media))
|
||||
?? selectedSeries?.rep
|
||||
?? null
|
||||
@@ -332,6 +340,7 @@ export function LibraryPage() {
|
||||
|
||||
<LibraryMediaSections
|
||||
isSeries={isSeries}
|
||||
total={total}
|
||||
items={displayedItems}
|
||||
seriesCards={displayedSeriesCards}
|
||||
selectedSeries={selectedSeries}
|
||||
@@ -349,6 +358,8 @@ export function LibraryPage() {
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
allEpisodes={selectedSeriesEpisodes}
|
||||
resume={seriesResume}
|
||||
episodeProgress={seriesEpisodeProgress}
|
||||
loadingEpisodes={loadingSeriesEpisodes}
|
||||
playbackFrom={`${location.pathname}${location.search}`}
|
||||
isAdmin={role === 'admin'}
|
||||
|
||||
@@ -8,10 +8,12 @@ import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||
import type { Media } from '../types'
|
||||
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
import { canContinuePlaying, continueButtonLabel, type MediaDetailResume } from './mediaDetailResumeModel'
|
||||
|
||||
type LibrarySeriesDetailHeaderProps = {
|
||||
series: SeriesCard
|
||||
allEpisodes: Media[]
|
||||
resume: MediaDetailResume | null
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
canFavorite: boolean
|
||||
@@ -32,6 +34,7 @@ type LibrarySeriesDetailHeaderProps = {
|
||||
export function LibrarySeriesDetailHeader({
|
||||
series,
|
||||
allEpisodes,
|
||||
resume,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
canFavorite,
|
||||
@@ -51,6 +54,9 @@ export function LibrarySeriesDetailHeader({
|
||||
const tvEpisodes = allEpisodes.filter((media) => !isTheatricalFeature(media))
|
||||
const theatricalCount = allEpisodes.length - tvEpisodes.length
|
||||
const firstEpisode = firstPlayableEpisode(tvEpisodes)
|
||||
// 看过的剧先给「继续播放 · 第N集 · mm:ss」,指向真正看的那一集;
|
||||
// 没有可用进度时保持原来的「从第一集开始播放」。
|
||||
const resumeTarget = resume && canContinuePlaying(resume) ? resume : null
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,14 +96,32 @@ export function LibrarySeriesDetailHeader({
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{firstEpisode && (
|
||||
{resumeTarget ? (
|
||||
<>
|
||||
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
|
||||
<Link to={`/play/${resumeTarget.mediaId}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
|
||||
<Play size={16} fill="currentColor" />
|
||||
从第一集开始播放
|
||||
{continueButtonLabel(resumeTarget)}
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
|
||||
<Link
|
||||
to={`/play/${resumeTarget.mediaId}?restart=1`}
|
||||
state={{ from: playbackFrom }}
|
||||
className="btn-outline inline-flex"
|
||||
title="忽略续播进度,从 0 开始播放"
|
||||
>
|
||||
从头播放
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={resumeTarget.mediaId} label="外部播放器播放" />
|
||||
</>
|
||||
) : (
|
||||
firstEpisode && (
|
||||
<>
|
||||
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
|
||||
<Play size={16} fill="currentColor" />
|
||||
从第一集开始播放
|
||||
</Link>
|
||||
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{canFavorite && (
|
||||
<MediaFavouriteButton
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
import type { Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import type { EpisodeProgress, MediaDetailResume } from './mediaDetailResumeModel'
|
||||
import { LibrarySeriesDetailHeader } from './LibrarySeriesDetailHeader'
|
||||
import { LibrarySeriesEpisodes } from './LibrarySeriesEpisodes'
|
||||
|
||||
@@ -16,6 +17,8 @@ type LibrarySeriesDetailSectionProps = {
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
allEpisodes: Media[]
|
||||
resume: MediaDetailResume | null
|
||||
episodeProgress: Map<string, EpisodeProgress>
|
||||
loadingEpisodes: boolean
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
@@ -42,6 +45,8 @@ export function LibrarySeriesDetailSection({
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
allEpisodes,
|
||||
resume,
|
||||
episodeProgress,
|
||||
loadingEpisodes,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
@@ -73,6 +78,7 @@ export function LibrarySeriesDetailSection({
|
||||
<LibrarySeriesDetailHeader
|
||||
series={selectedSeries}
|
||||
allEpisodes={allEpisodes}
|
||||
resume={resume}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
canFavorite={canFavorite}
|
||||
@@ -95,6 +101,7 @@ export function LibrarySeriesDetailSection({
|
||||
selectedEpisodes={selectedEpisodes}
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
progressById={episodeProgress}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
onSeasonChange={onSeasonChange}
|
||||
@@ -111,6 +118,7 @@ type LibrarySeriesEpisodesPanelProps = {
|
||||
selectedEpisodes: SeasonEpisodes[]
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
progressById: Map<string, EpisodeProgress>
|
||||
playbackFrom: string
|
||||
isAdmin: boolean
|
||||
onSeasonChange: (season: number) => void
|
||||
@@ -122,6 +130,7 @@ function LibrarySeriesEpisodesPanel({
|
||||
selectedEpisodes,
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
progressById,
|
||||
playbackFrom,
|
||||
isAdmin,
|
||||
onSeasonChange,
|
||||
@@ -134,6 +143,7 @@ function LibrarySeriesEpisodesPanel({
|
||||
selectedEpisodes={selectedEpisodes}
|
||||
selectedSeason={selectedSeason}
|
||||
visibleEpisodes={visibleEpisodes}
|
||||
progressById={progressById}
|
||||
playbackFrom={playbackFrom}
|
||||
isAdmin={isAdmin}
|
||||
onSeasonChange={onSeasonChange}
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
} from '../utils/groupSeries'
|
||||
import { formatSize } from './libraryPageModel'
|
||||
import { formatEpisodeLabel, formatEpisodeNumber } from '../utils/episodeNumber'
|
||||
import {
|
||||
formatResumeClock,
|
||||
progressPercent,
|
||||
type EpisodeProgress,
|
||||
} from './mediaDetailResumeModel'
|
||||
|
||||
type SeasonGroup = {
|
||||
season: number
|
||||
@@ -24,6 +29,8 @@ type LibrarySeriesEpisodesProps = {
|
||||
selectedSeason: number | null
|
||||
visibleEpisodes: Media[]
|
||||
playbackFrom: string
|
||||
/** 每集(按条目 id)的续播进度,用于卡片上的进度条。 */
|
||||
progressById?: Map<string, EpisodeProgress>
|
||||
isAdmin?: boolean
|
||||
onSeasonChange: (season: number) => void
|
||||
onManualScrape?: (media: Media) => void
|
||||
@@ -35,6 +42,7 @@ export function LibrarySeriesEpisodes({
|
||||
selectedSeason,
|
||||
visibleEpisodes,
|
||||
playbackFrom,
|
||||
progressById,
|
||||
isAdmin = false,
|
||||
onSeasonChange,
|
||||
onManualScrape,
|
||||
@@ -76,6 +84,7 @@ export function LibrarySeriesEpisodes({
|
||||
{visibleEpisodes.map((ep) => {
|
||||
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
|
||||
const versionCount = ep.versions?.length ?? 1
|
||||
const progress = progressById?.get(ep.id)
|
||||
return (
|
||||
<div
|
||||
key={ep.id}
|
||||
@@ -117,6 +126,21 @@ export function LibrarySeriesEpisodes({
|
||||
: formatSize(ep.size_bytes)}
|
||||
{versionCount > 1 ? ` · ${versionCount} 版本` : ''}
|
||||
</p>
|
||||
{progress && (
|
||||
<div className="mt-1 flex items-center gap-1.5" title={progressTitle(progress)}>
|
||||
<div className="h-1 w-14 overflow-hidden rounded-full bg-sand-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-500"
|
||||
style={{ width: `${progressPercent(progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-brand-600 whitespace-nowrap">
|
||||
{progress.completed
|
||||
? '已看完'
|
||||
: `看到 ${formatResumeClock(progress.positionMs)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -142,6 +166,13 @@ export function LibrarySeriesEpisodes({
|
||||
)
|
||||
}
|
||||
|
||||
function progressTitle(progress: EpisodeProgress): string {
|
||||
if (progress.completed) return '已看完'
|
||||
const position = formatResumeClock(progress.positionMs)
|
||||
const duration = formatResumeClock(progress.durationMs)
|
||||
return duration ? `看到 ${position} / ${duration}` : `看到 ${position}`
|
||||
}
|
||||
|
||||
function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
|
||||
let mainTitle = ''
|
||||
const epTitle = ep.episode_title?.trim()
|
||||
|
||||
@@ -33,6 +33,7 @@ export function MediaDetailPage() {
|
||||
isAdmin={role === 'admin'}
|
||||
favourite={detail.favourite}
|
||||
playTargetId={media.id || detail.firstPlayableEpisode?.id}
|
||||
resume={detail.resume}
|
||||
scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork}
|
||||
onToggleFavourite={detail.toggleFavourite}
|
||||
onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork}
|
||||
@@ -53,6 +54,7 @@ export function MediaDetailPage() {
|
||||
selectedEpisodes={detail.seasonGroups}
|
||||
selectedSeason={detail.selectedSeason}
|
||||
visibleEpisodes={detail.visibleEpisodes}
|
||||
progressById={detail.episodeProgress}
|
||||
playbackFrom={`/media/${media.id}`}
|
||||
onSeasonChange={detail.setSelectedSeason}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
|
||||
import type { Media } from '../types'
|
||||
import { isDirectStreamMedia } from './playerPageModel'
|
||||
import { canContinuePlaying, continueButtonLabel, type MediaDetailResume } from './mediaDetailResumeModel'
|
||||
import { MediaDetailAdminPanel } from './MediaDetailAdminPanel'
|
||||
import { MediaDetailPoster } from './MediaDetailArtwork'
|
||||
import { MediaDetailMetadata } from './MediaDetailMetadata'
|
||||
@@ -20,6 +21,7 @@ interface MediaDetailPlaybackActionsProps {
|
||||
favourite: boolean
|
||||
onToggleFavourite: () => void
|
||||
playTargetId?: string
|
||||
resume?: MediaDetailResume | null
|
||||
}
|
||||
|
||||
interface MediaDetailMainContentProps extends MediaDetailPlaybackActionsProps {
|
||||
@@ -96,8 +98,12 @@ export function MediaDetailPlaybackActions({
|
||||
favourite,
|
||||
onToggleFavourite,
|
||||
playTargetId,
|
||||
resume,
|
||||
}: MediaDetailPlaybackActionsProps) {
|
||||
const targetId = media.id || playTargetId || ''
|
||||
// 代表条目可能是 OVA/特别篇:有续播记录时优先打开历史命中的那一集(或那集的
|
||||
// 其它版本),并直接把「看到第几秒」写在按钮上。播放器仍会自己读 /resume。
|
||||
const resumeTarget = resume && canContinuePlaying(resume) ? resume : null
|
||||
const targetId = resumeTarget?.mediaId || media.id || playTargetId || ''
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<Link
|
||||
@@ -106,9 +112,20 @@ export function MediaDetailPlaybackActions({
|
||||
className="btn-primary h-11 px-6 shadow-sm"
|
||||
>
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span>立即播放</span>
|
||||
<span>{resumeTarget ? continueButtonLabel(resumeTarget) : '立即播放'}</span>
|
||||
</Link>
|
||||
|
||||
{resumeTarget && (
|
||||
<Link
|
||||
to={`/play/${resumeTarget.mediaId}?restart=1`}
|
||||
state={{ from: `/media/${media.id}` }}
|
||||
className="btn-ghost h-11 px-4 text-sm"
|
||||
title="忽略续播进度,从 0 开始播放"
|
||||
>
|
||||
<span>从头播放</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!isDirectStreamMedia(media) && (
|
||||
<Link
|
||||
to={`/play/${targetId}?mode=hls`}
|
||||
@@ -143,6 +160,7 @@ export function MediaDetailMainContent({
|
||||
isAdmin,
|
||||
favourite,
|
||||
playTargetId,
|
||||
resume,
|
||||
scrapeEpisodeArtwork,
|
||||
onToggleFavourite,
|
||||
onScrapeEpisodeArtworkChange,
|
||||
@@ -167,6 +185,7 @@ export function MediaDetailMainContent({
|
||||
favourite={favourite}
|
||||
onToggleFavourite={onToggleFavourite}
|
||||
playTargetId={playTargetId}
|
||||
resume={resume}
|
||||
/>
|
||||
<MediaVersionSwitcher media={media} />
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
//
|
||||
// ?mode=hls force HLS even when direct play would work
|
||||
// ?mode=direct force direct play (default for browser-friendly codecs)
|
||||
// ?restart=1 忽略服务端续播进度,从 0 开始播(详情页的「从头播放」入口)
|
||||
//
|
||||
// ?mode= 只在进入某个媒体时读取一次(媒体详情页的「HLS 兼容转码播放」入口依赖它);
|
||||
// 播放模式不再写回 URL——写 URL 会让 search 变化触发依赖 setSearchParams 的 effect
|
||||
@@ -102,6 +103,17 @@ function formatMarkTime(totalSec: number): string {
|
||||
const CLOUD_HLS_RELOAD_MAX = 3
|
||||
const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 逐集切换时保留 ?mode=(HLS/直连是整段会话的选择),但丢掉 ?restart=1:
|
||||
* 「从头播放」只对用户点的那一集生效,下一集仍按自己的续播进度起播。
|
||||
*/
|
||||
function searchWithoutRestart(search: string): string {
|
||||
const params = new URLSearchParams(search)
|
||||
params.delete('restart')
|
||||
const next = params.toString()
|
||||
return next ? `?${next}` : ''
|
||||
}
|
||||
|
||||
function normalizePlayerVolume(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
@@ -177,6 +189,11 @@ export function PlayerPage() {
|
||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||
const diagnosticModeRef = useRef(mode)
|
||||
useEffect(() => { diagnosticModeRef.current = mode }, [mode])
|
||||
// modeRef / mediaRef 会被声明点之上的回调读到(handleDirectSeekStalled、
|
||||
// fallbackDirectPlay、toggleVr360 等),所以先在这里声明,赋值仍由下方各自的
|
||||
// 同步 effect 负责;否则以后有人在渲染期调用这些回调就会踩 TDZ。
|
||||
const modeRef = useRef(mode)
|
||||
const mediaRef = useRef(media)
|
||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false)
|
||||
const [diagnosticEvents, setDiagnosticEvents] = useState<PlayerDiagnosticEvent[]>([])
|
||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||
@@ -202,6 +219,12 @@ export function PlayerPage() {
|
||||
const subtitlePreferenceTouchedRef = useRef(false)
|
||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
// 播放接线 effect 里只有一条提示需要读它,用 ref 读即可保持最新;如果放进依赖,
|
||||
// 这个 effect 会因为自己写出来的状态而整体重建(teardownHls + DELETE 转码任务)。
|
||||
const hlsUnavailableRef = useRef(false)
|
||||
useEffect(() => {
|
||||
hlsUnavailableRef.current = hlsUnavailable
|
||||
}, [hlsUnavailable])
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
|
||||
const [loadError, setLoadError] = useState('')
|
||||
@@ -542,6 +565,12 @@ export function PlayerPage() {
|
||||
setPlaybackInfo(null)
|
||||
setHlsSource('local')
|
||||
setSelectedQuality('')
|
||||
// HLS 可用性与字幕列表都是「上一条媒体」得出的结论,必须跟着媒体一起清掉:
|
||||
// 否则某一集 HLS 失败一次,之后每集直连失败就再也不会回退转码(只弹「HLS 不可用」);
|
||||
// 字幕列表留着旧条目还会用新 media.id 拼旧 path 去请求一次。
|
||||
setHlsUnavailable(false)
|
||||
setSubs([])
|
||||
setSubtitleIndex(-1)
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
setCloudWaitStartedAt(0)
|
||||
@@ -1258,7 +1287,6 @@ export function PlayerPage() {
|
||||
// 作为播放 effect 的依赖,会在 STRM 的 302 直链仍在建立时重复设置 src,
|
||||
// Chromium 会把被中断的首次加载报告成播放错误并误触发 HLS 回退。
|
||||
const activeBurnedSubtitleStream = mode === 'hls' ? burnedSubtitleStream : undefined
|
||||
const mediaRef = useRef(media)
|
||||
useEffect(() => {
|
||||
mediaRef.current = media
|
||||
directOnlyRef.current = directOnly
|
||||
@@ -1526,7 +1554,7 @@ export function PlayerPage() {
|
||||
// 起播兜底:上游不回 error 时会一直停在 readyState=0,见 armDirectStallWatchdog。
|
||||
armDirectStallWatchdog()
|
||||
}
|
||||
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
|
||||
if (hlsUnavailableRef.current && needsTranscodeForBrowser(currentMedia)) {
|
||||
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
|
||||
}
|
||||
}
|
||||
@@ -1555,7 +1583,6 @@ export function PlayerPage() {
|
||||
cloudReloadNonce,
|
||||
cloudWaiting,
|
||||
hlsSource,
|
||||
hlsUnavailable,
|
||||
hlsStartSec,
|
||||
mediaId,
|
||||
mode,
|
||||
@@ -1575,6 +1602,8 @@ export function PlayerPage() {
|
||||
if (!id) return
|
||||
setResumePosition(0)
|
||||
setInitialSeekDone(false)
|
||||
// 详情页的「从头播放」显式要求忽略续播:不再去读 /resume,直接从 0 起播。
|
||||
if (new URLSearchParams(window.location.search).get('restart') === '1') return
|
||||
const controller = new AbortController()
|
||||
playbackAPI
|
||||
.getResume(id, controller.signal)
|
||||
@@ -1590,22 +1619,39 @@ export function PlayerPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!resumePosition || initialSeekDone) return
|
||||
let cancelled = false
|
||||
if (mode === 'hls' && hlsSource === 'local') {
|
||||
// Restart transcode near the resume point instead of seeking a short partial playlist.
|
||||
if (Math.abs(hlsStartSec - resumePosition) > 2) {
|
||||
setHlsStartSec(resumePosition)
|
||||
}
|
||||
setInitialSeekDone(true)
|
||||
const m = Math.floor(resumePosition / 60)
|
||||
const s = Math.floor(resumePosition % 60)
|
||||
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
|
||||
return
|
||||
// 本地 HLS 的播放列表 t=0 就是 resumePosition,位置由转码起点决定;但清单还没
|
||||
// 回来、转码也可能直接失败,此刻同步弹「已恢复」就是一句空承诺。等画面真的出
|
||||
// 数据(loadeddata)再报,和下面直连分支的行为保持一致。
|
||||
const hlsVideo = ref.current
|
||||
if (!hlsVideo) return
|
||||
const confirmResume = () => {
|
||||
if (cancelled) return
|
||||
const el = ref.current
|
||||
if (!el || el.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return
|
||||
setInitialSeekDone(true)
|
||||
const m = Math.floor(resumePosition / 60)
|
||||
const s = Math.floor(resumePosition % 60)
|
||||
toast.success(`已恢复上次播放进度至 ${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`, { duration: 2500 })
|
||||
}
|
||||
if (hlsVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
confirmResume()
|
||||
return
|
||||
}
|
||||
hlsVideo.addEventListener('loadeddata', confirmResume, { once: true })
|
||||
return () => {
|
||||
cancelled = true
|
||||
hlsVideo.removeEventListener('loadeddata', confirmResume)
|
||||
}
|
||||
}
|
||||
const video = ref.current
|
||||
if (!video) return
|
||||
|
||||
let cancelled = false
|
||||
let seekStarted = false
|
||||
let onPlaying: (() => void) | undefined
|
||||
let onCanPlay: (() => void) | undefined
|
||||
@@ -1696,7 +1742,6 @@ export function PlayerPage() {
|
||||
|
||||
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
|
||||
const hlsStartSecRef = useRef(hlsStartSec)
|
||||
const modeRef = useRef(mode)
|
||||
useEffect(() => {
|
||||
hlsStartSecRef.current = hlsStartSec
|
||||
hlsSourceRef.current = hlsSource
|
||||
@@ -1824,7 +1869,7 @@ export function PlayerPage() {
|
||||
const target = autoPlayEpisodeFor(media, id, playlistEpisodes)
|
||||
if (!target) return
|
||||
navigate(
|
||||
{ pathname: `/play/${target.id}`, search: location.search },
|
||||
{ pathname: `/play/${target.id}`, search: searchWithoutRestart(location.search) },
|
||||
{ replace: true, state: location.state },
|
||||
)
|
||||
}, [media, id, playlistEpisodes, navigate, location.search, location.state])
|
||||
@@ -1906,7 +1951,9 @@ export function PlayerPage() {
|
||||
navigate(
|
||||
{
|
||||
pathname: `/play/${target.id}`,
|
||||
search: location.search,
|
||||
// 逐集切换保留 ?mode=,但丢掉 ?restart=1:「从头播放」只对用户点的那一集
|
||||
// 生效,下一集仍按各自的续播进度起播。
|
||||
search: searchWithoutRestart(location.search),
|
||||
},
|
||||
{ state: location.state },
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
Ban,
|
||||
Cloud,
|
||||
CloudDownload,
|
||||
Download,
|
||||
FolderPlus,
|
||||
HardDrive,
|
||||
@@ -206,6 +208,10 @@ export function StrmManagePage() {
|
||||
<FolderPlus size={16} />
|
||||
添加同步目录
|
||||
</button>
|
||||
<Link to="/cloud-downloads" className="neon-button">
|
||||
<CloudDownload size={16} />
|
||||
云下载
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
canContinuePlaying,
|
||||
continueButtonLabel,
|
||||
episodeProgressById,
|
||||
formatResumeClock,
|
||||
progressPercent,
|
||||
resolveMediaDetailResume,
|
||||
} from './mediaDetailResumeModel.ts'
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
|
||||
function check(name: string, cond: boolean) {
|
||||
if (!cond) throw new Error(`mediaDetailResumeModel: ${name}`)
|
||||
}
|
||||
|
||||
function media(partial: Partial<Media> & { id: string }): Media {
|
||||
return {
|
||||
library_id: 'lib-1',
|
||||
title: '标题',
|
||||
path: `/media/${partial.id}.mkv`,
|
||||
size_bytes: 0,
|
||||
duration_sec: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
rating: 0,
|
||||
year: 2024,
|
||||
season_num: 0,
|
||||
episode_num: 0,
|
||||
scrape_status: 'matched',
|
||||
tmdb_id: 0,
|
||||
bangumi_id: 0,
|
||||
nsfw: false,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
...partial,
|
||||
} as Media
|
||||
}
|
||||
|
||||
function historyRow(
|
||||
mediaID: string,
|
||||
positionMs: number,
|
||||
watchedAt: string,
|
||||
extra: Partial<HistoryItem> = {},
|
||||
): HistoryItem {
|
||||
return {
|
||||
id: `h-${mediaID}`,
|
||||
user_id: 'u1',
|
||||
media_id: mediaID,
|
||||
position_ms: positionMs,
|
||||
duration_ms: 1_440_000,
|
||||
watched_at: watchedAt,
|
||||
completed: false,
|
||||
...extra,
|
||||
} as HistoryItem
|
||||
}
|
||||
|
||||
// ── 电影:直接用本条的进度 ────────────────────────────────────────────────
|
||||
const movie = media({ id: 'movie-1' })
|
||||
const movieResume = resolveMediaDetailResume(movie, [], [historyRow('movie-1', 754_000, '2024-05-01T10:00:00Z')])
|
||||
check('movie resume found', movieResume?.mediaId === 'movie-1')
|
||||
check('movie label', continueButtonLabel(movieResume!) === '继续播放 · 12:34')
|
||||
check('movie can continue', canContinuePlaying(movieResume))
|
||||
|
||||
// ── 剧集:代表条目是 OVA,续播要指向真正看过的那一集 ──────────────────────
|
||||
const ova = media({ id: 'ova', episode_num: 0, title: '特别篇' })
|
||||
const ep1 = media({ id: 'ep1', season_num: 1, episode_num: 1 })
|
||||
const ep2 = media({ id: 'ep2', season_num: 1, episode_num: 2 })
|
||||
const ep3 = media({ id: 'ep3', season_num: 1, episode_num: 3 })
|
||||
const episodes = [ep1, ep2, ep3]
|
||||
const seriesHistory = [
|
||||
historyRow('ep1', 600_000, '2024-05-01T09:00:00Z', { completed: true }),
|
||||
historyRow('ep2', 300_000, '2024-05-02T09:00:00Z'),
|
||||
historyRow('ep3', 0, '2024-05-03T09:00:00Z'),
|
||||
historyRow('other-show', 900_000, '2024-05-04T09:00:00Z'),
|
||||
]
|
||||
const seriesResume = resolveMediaDetailResume(ova, episodes, seriesHistory)
|
||||
check('series picks newest watched episode', seriesResume?.mediaId === 'ep2')
|
||||
check('series label', continueButtonLabel(seriesResume!) === '继续播放 · 第 2 集 · 5:00')
|
||||
|
||||
// 命中的是某一集的版本时,打开版本、但集号仍按它属于哪一集显示。
|
||||
const ep2Uhd = media({ id: 'ep2-uhd', season_num: 1, episode_num: 2 })
|
||||
const versionedEpisodes = [ep1, { ...ep2, versions: [ep2Uhd] }, ep3]
|
||||
const versionResume = resolveMediaDetailResume(ova, versionedEpisodes, [
|
||||
historyRow('ep2-uhd', 120_000, '2024-05-02T09:00:00Z'),
|
||||
])
|
||||
check('version resume target', versionResume?.mediaId === 'ep2-uhd')
|
||||
check('version resume label uses owning episode', versionResume?.media.episode_num === 2)
|
||||
|
||||
// ── 不值得续播的情况 ──────────────────────────────────────────────────────
|
||||
check(
|
||||
'near-start is not a resume',
|
||||
!canContinuePlaying(resolveMediaDetailResume(movie, [], [historyRow('movie-1', 1_500, '2024-05-01T10:00:00Z')])),
|
||||
)
|
||||
check(
|
||||
'completed is not a resume',
|
||||
!canContinuePlaying(
|
||||
resolveMediaDetailResume(movie, [], [
|
||||
historyRow('movie-1', 1_400_000, '2024-05-01T10:00:00Z', { completed: true }),
|
||||
]),
|
||||
),
|
||||
)
|
||||
check('no history', resolveMediaDetailResume(movie, [], []) === null)
|
||||
check(
|
||||
'unrelated history is ignored',
|
||||
resolveMediaDetailResume(movie, [], [historyRow('other', 900_000, '2024-05-01T10:00:00Z')]) === null,
|
||||
)
|
||||
|
||||
// ── 时间格式 ──────────────────────────────────────────────────────────────
|
||||
check('clock minutes', formatResumeClock(754_000) === '12:34')
|
||||
check('clock hours', formatResumeClock(3_723_000) === '1:02:03')
|
||||
check('clock invalid', formatResumeClock(0) === '' && formatResumeClock(Number.NaN) === '')
|
||||
|
||||
// ── 每集进度(剧集卡片上的进度条) ────────────────────────────────────────
|
||||
const progress = episodeProgressById(versionedEpisodes, [
|
||||
historyRow('ep1', 700_000, '2024-05-01T09:00:00Z', { completed: true }),
|
||||
historyRow('ep2', 200_000, '2024-05-01T09:10:00Z'),
|
||||
historyRow('ep2-uhd', 400_000, '2024-05-01T09:20:00Z'),
|
||||
])
|
||||
check('episode progress skips untouched episodes', !progress.has('ep3'))
|
||||
check('episode progress prefers newest version row', progress.get('ep2')?.positionMs === 400_000)
|
||||
check('completed episode is full', progressPercent(progress.get('ep1')!) === 100)
|
||||
check('partial episode is capped', progressPercent({ positionMs: 1_430_000, durationMs: 1_440_000, completed: false }) === 99)
|
||||
check('unknown duration still visible', progressPercent({ positionMs: 10_000, durationMs: 0, completed: false }) === 5)
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
import { formatEpisodeLabel } from '../utils/episodeNumber.ts'
|
||||
|
||||
/**
|
||||
* 媒体详情页的「继续播放」解析。
|
||||
*
|
||||
* 服务端存的是「(user, media) → 最近位置」的续播行。详情页要回答两个问题:
|
||||
* 1. 这部剧最新看到哪一集?(代表条目可能是 OVA/特别篇,不能直接用它自己的进度)
|
||||
* 2. 那一集看到第几秒?
|
||||
*
|
||||
* 因此这里拿到的是「整库最近 N 条历史」,在「本条目 + 全部剧集(含各自的版本)」
|
||||
* 这个集合里挑 watched_at 最新的一条。历史请求用 include_media=false,服务端
|
||||
* 不需要 join media,也不会为挂载条目逐条回源。
|
||||
*/
|
||||
|
||||
/** 播放器只在「>2 秒且未看完」时才真正续播,详情页用同一阈值决定按钮文案。 */
|
||||
export const RESUME_MIN_POSITION_MS = 2000
|
||||
|
||||
/** 详情页续播扫描的条数(服务端上限 500)。 */
|
||||
export const RESUME_SCAN_LIMIT = 300
|
||||
|
||||
export interface MediaDetailResume {
|
||||
/** 实际要打开的媒体 id:命中的那条历史,可能是某集的某个版本。 */
|
||||
mediaId: string
|
||||
/** 用来显示集号/标题的条目(命中的那集;电影就是本体)。 */
|
||||
media: Media
|
||||
positionMs: number
|
||||
durationMs: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export interface EpisodeProgress {
|
||||
positionMs: number
|
||||
durationMs: number
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
function versionIDs(media: Media | null | undefined): string[] {
|
||||
return (media?.versions ?? []).map((v) => v.id).filter(Boolean)
|
||||
}
|
||||
|
||||
/** 「本条 + 各集 + 各集版本」的 id 集合:续播候选范围。 */
|
||||
export function mediaDetailScopeIDs(media: Media | null, episodes: Media[]): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
const add = (item: Media | null | undefined) => {
|
||||
if (!item?.id) return
|
||||
ids.add(item.id)
|
||||
for (const id of versionIDs(item)) ids.add(id)
|
||||
}
|
||||
add(media)
|
||||
for (const ep of episodes) add(ep)
|
||||
return ids
|
||||
}
|
||||
|
||||
/** 命中的 history.media_id 属于哪个条目:集优先,命中的是版本时归到它那一集。 */
|
||||
function ownerOf(mediaID: string, media: Media | null, episodes: Media[]): Media | null {
|
||||
for (const ep of episodes) {
|
||||
if (ep.id === mediaID) return ep
|
||||
if (versionIDs(ep).includes(mediaID)) return ep
|
||||
}
|
||||
if (media) {
|
||||
if (media.id === mediaID) return media
|
||||
if (versionIDs(media).includes(mediaID)) return media
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function watchedAtScore(row: HistoryItem): number {
|
||||
const at = Date.parse(row.watched_at)
|
||||
return Number.isFinite(at) ? at : 0
|
||||
}
|
||||
|
||||
function newestRow(rows: HistoryItem[]): HistoryItem | null {
|
||||
let best: HistoryItem | null = null
|
||||
for (const row of rows) {
|
||||
if (!row || !row.media_id) continue
|
||||
if (best == null || watchedAtScore(row) > watchedAtScore(best)) best = row
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页的续播目标;没有可用历史时返回 null(按钮退回「立即播放」)。
|
||||
*/
|
||||
export function resolveMediaDetailResume(
|
||||
media: Media | null,
|
||||
episodes: Media[],
|
||||
history: HistoryItem[],
|
||||
): MediaDetailResume | null {
|
||||
if (!media) return null
|
||||
const ids = mediaDetailScopeIDs(media, episodes)
|
||||
if (ids.size === 0) return null
|
||||
const rows = history.filter((row) => row?.media_id && ids.has(row.media_id) && row.position_ms > 0)
|
||||
const best = newestRow(rows)
|
||||
if (!best) return null
|
||||
return {
|
||||
mediaId: best.media_id,
|
||||
media: ownerOf(best.media_id, media, episodes) ?? media,
|
||||
positionMs: best.position_ms,
|
||||
durationMs: best.duration_ms,
|
||||
completed: Boolean(best.completed),
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否该显示「继续播放」(与播放器真正会续播的条件保持一致)。 */
|
||||
export function canContinuePlaying(resume: MediaDetailResume | null): boolean {
|
||||
if (!resume) return false
|
||||
return !resume.completed && resume.positionMs > RESUME_MIN_POSITION_MS
|
||||
}
|
||||
|
||||
/** 12:34 / 1:02:03;非法输入返回空串。 */
|
||||
export function formatResumeClock(positionMs: number): string {
|
||||
if (!Number.isFinite(positionMs) || positionMs <= 0) return ''
|
||||
const totalSec = Math.floor(positionMs / 1000)
|
||||
const hours = Math.floor(totalSec / 3600)
|
||||
const minutes = Math.floor((totalSec % 3600) / 60)
|
||||
const seconds = totalSec % 60
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
|
||||
}
|
||||
|
||||
/** 「继续播放 · 第 3 集 · 12:34」;电影没有集号时只带位置。 */
|
||||
export function continueButtonLabel(resume: MediaDetailResume): string {
|
||||
const parts = ['继续播放']
|
||||
const episode = formatEpisodeLabel(resume.media)
|
||||
if (episode) parts.push(episode)
|
||||
const clock = formatResumeClock(resume.positionMs)
|
||||
if (clock) parts.push(clock)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/** 每集(含版本)的续播进度,用于剧集卡片上的进度条。 */
|
||||
export function episodeProgressById(
|
||||
episodes: Media[],
|
||||
history: HistoryItem[],
|
||||
): Map<string, EpisodeProgress> {
|
||||
const map = new Map<string, EpisodeProgress>()
|
||||
if (episodes.length === 0 || history.length === 0) return map
|
||||
const byMediaID = new Map<string, HistoryItem>()
|
||||
for (const row of history) {
|
||||
if (row?.media_id && row.position_ms > 0) byMediaID.set(row.media_id, row)
|
||||
}
|
||||
for (const ep of episodes) {
|
||||
const rows: HistoryItem[] = []
|
||||
for (const id of [ep.id, ...versionIDs(ep)]) {
|
||||
const row = byMediaID.get(id)
|
||||
if (row) rows.push(row)
|
||||
}
|
||||
const best = newestRow(rows)
|
||||
if (!best) continue
|
||||
map.set(ep.id, {
|
||||
positionMs: best.position_ms,
|
||||
durationMs: best.duration_ms,
|
||||
completed: Boolean(best.completed),
|
||||
})
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** 进度条宽度(0-100)。时长未知时按「看过」显示一小段,避免整条空着。 */
|
||||
export function progressPercent(progress: EpisodeProgress): number {
|
||||
if (progress.completed) return 100
|
||||
if (progress.durationMs > 0) {
|
||||
const ratio = (progress.positionMs / progress.durationMs) * 100
|
||||
return Math.min(99, Math.max(2, ratio))
|
||||
}
|
||||
return 5
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Gauge, ListMusic, Loader2, Pause, Play, Scissors, Timer, X } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Gauge, ListMusic, Loader2, Pause, Play, RefreshCw, Scissors, Timer, X } from 'lucide-react'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
|
||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||
@@ -196,9 +196,21 @@ export function ReaderAudioPanel({ theme, onToggleMenu }: ReaderAudioPanelProps)
|
||||
</p>
|
||||
<p className="mt-1 text-2xs opacity-60" style={{ color: theme.text }}>
|
||||
{audioError ? (
|
||||
<span className="font-bold" style={{ color: theme.accent }}>
|
||||
{audioError}
|
||||
</span>
|
||||
// 失败态下这行就是重试入口:服务端这一轮要十几秒才判定失败,
|
||||
// 与其让用户对着转圈,不如把「为什么」和「再试一次」一起给他。
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
engine.retry()
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-2xs font-bold opacity-100"
|
||||
style={{ backgroundColor: theme.accent + '1a', color: theme.accent }}
|
||||
title="重新请求音源"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{audioError} · 立即重试
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{loading ? (transcoding ? '服务端转码中,请稍候…' : '加载中…') : playing ? '播放中' : '已暂停'} ·
|
||||
|
||||
@@ -22,8 +22,15 @@ import { readerCoverSrc } from '../../utils/readerCover'
|
||||
const PROGRESS_SAVE_INTERVAL_MS = 5_000
|
||||
/** -15s / +15s 步长(对齐面板与 legado SEEK_STEP)。 */
|
||||
const SEEK_STEP = 15
|
||||
/** 音源加载失败后自动重试(重新换取签名地址)的次数上限。 */
|
||||
const AUDIO_RETRY_LIMIT = 2
|
||||
/**
|
||||
* 音源加载失败后自动重试的退避间隔(毫秒),数组长度即自动重试次数。
|
||||
*
|
||||
* 别调得太短:服务端媒体代理要回源换票、拉整章音频,失败一次本身就要十几秒,
|
||||
* 立刻重试只会让用户对着转圈更久。第一次 3 秒、第二次 9 秒,总等待可控。
|
||||
*/
|
||||
const AUDIO_RETRY_DELAYS_MS = [3_000, 9_000]
|
||||
/** 自动重试次数上限(与退避表的长度一致)。 */
|
||||
const AUDIO_RETRY_LIMIT = AUDIO_RETRY_DELAYS_MS.length
|
||||
|
||||
export interface LoadChapterInput {
|
||||
bookId: string
|
||||
@@ -183,10 +190,23 @@ class ReaderAudioEngine {
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
// 失败态下播放键就是「重试」:重新解析本章音轨(换一张签名票)再装源,
|
||||
// 而不是对着一份已经出错的 src 再调 play()。
|
||||
if (useReaderAudioStore.getState().status === 'error') {
|
||||
this.retry()
|
||||
return
|
||||
}
|
||||
if (this.audio.paused) this.play()
|
||||
else this.pause()
|
||||
}
|
||||
|
||||
/** 用户手动重试:清掉自动重试计数与待执行定时器,立刻重新解析本章音轨。 */
|
||||
retry(): void {
|
||||
this.resetRetry()
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
void this.reloadCurrentChapter()
|
||||
}
|
||||
|
||||
seekBy(delta: number): void {
|
||||
const total = Number.isFinite(this.audio.duration)
|
||||
? this.audio.duration
|
||||
@@ -482,13 +502,23 @@ class ReaderAudioEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/** 退避后再重新解析本章音轨;期间保持 loading 态,不给用户闪错误。 */
|
||||
/**
|
||||
* 退避后再重新解析本章音轨。
|
||||
*
|
||||
* 期间刻意停留在 error 态而不是 loading:服务端这次请求本来就要十几秒才判定
|
||||
* 失败,再挂一个转圈只会让人以为卡死。明确告诉他「几秒后自动重试」,并且这
|
||||
* 行提示本身可以点,等于把等待权交回用户。
|
||||
*/
|
||||
private scheduleRetry(): void {
|
||||
if (this.retryTimer !== null) window.clearTimeout(this.retryTimer)
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
const delay = 800 * this.retryCount
|
||||
const delay = AUDIO_RETRY_DELAYS_MS[Math.min(this.retryCount - 1, AUDIO_RETRY_LIMIT - 1)]
|
||||
useReaderAudioStore.setState({
|
||||
status: 'error',
|
||||
error: `音源暂时取不到,${Math.round(delay / 1000)} 秒后自动重试`,
|
||||
})
|
||||
this.retryTimer = window.setTimeout(() => {
|
||||
this.retryTimer = null
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
void this.reloadCurrentChapter()
|
||||
}, delay)
|
||||
}
|
||||
@@ -615,7 +645,7 @@ class ReaderAudioEngine {
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
const code = audio.error?.code
|
||||
// 服务端代理拉的是腾讯 CDN 的直链,跨境链路会偶发 502 / 中途断流。
|
||||
// 服务端代理拉的是第三方 CDN 的直链,跨境链路会偶发被上游拒绝 / 中途断流。
|
||||
// 浏览器对这两种情况都报 SRC_NOT_SUPPORTED(看起来像「格式不支持」),
|
||||
// 实际上重新取一次章节(换一张签名票)通常就能播,所以先自动重试。
|
||||
const retryable =
|
||||
@@ -625,11 +655,12 @@ class ReaderAudioEngine {
|
||||
this.scheduleRetry()
|
||||
return
|
||||
}
|
||||
// 重试用完:文案要能解释「为什么播不了」,并且面板上这行本身就是重试按钮。
|
||||
this.fail(
|
||||
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
? '音频加载失败:服务端多次重试后仍取不到音源,请稍后再试'
|
||||
? '音源暂时被上游拒绝'
|
||||
: code === MediaError.MEDIA_ERR_NETWORK
|
||||
? '音频加载失败,请检查网络后重试'
|
||||
? '网络异常,音源没取到'
|
||||
: '音频无法播放',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -129,17 +129,17 @@ export const generalSettingsGroup: SettingGroup = {
|
||||
},
|
||||
{
|
||||
key: 'cache.images_max_size_mb',
|
||||
label: '图片缓存上限 (MB)',
|
||||
label: '磁盘缓存总容量上限 (MB)',
|
||||
type: 'number',
|
||||
hint: '海报、剧照等图片代理缓存目录 (cache/images) 的总占用上限。超过上限时先淘汰原图、再按修改时间清理最旧的缩略图;上限过小会导致旧图被淘汰后又要重新从上游/挂载的 Emby 下载。设为 0 表示不限制,默认 2000MB',
|
||||
defaultValue: '2000',
|
||||
placeholder: '2000',
|
||||
hint: '磁盘缓存(hls、images、library-covers)的总占用上限。超过上限时严格按「转码 HLS → 图片 → 媒体库封面」的顺序淘汰最旧的文件,直到回落到上限的 80%。有声书转码缓存(reader-audio)另有 1GB 写入时淘汰,不计入这里。设为 0 表示不限制,默认 2048MB(2GB)',
|
||||
defaultValue: '2048',
|
||||
placeholder: '2048',
|
||||
},
|
||||
{
|
||||
key: 'cache.images_originals_max_size_mb',
|
||||
label: '原图缓存上限 (MB)',
|
||||
label: '图片原图缓存上限 (MB)',
|
||||
type: 'number',
|
||||
hint: '原图只是生成缩略图的原料,丢了可以重新回源,因此单独给一个小配额(默认 256MB)。总量超限时原图也优先被淘汰,保证客户端实际读取的缩略图能留住。设为 0 表示不单独限制',
|
||||
hint: 'originals 子目录的独立配额(默认 256MB)。原图只是生成缩略图的原料,丢了可以重新回源,因此单独给小配额;图片池被淘汰时优先删原图,保证客户端实际读取的缩略图能留住。设为 0 表示不单独限制',
|
||||
defaultValue: '256',
|
||||
placeholder: '256',
|
||||
},
|
||||
|
||||
@@ -3,13 +3,19 @@ import { useLocation, type NavigateFunction } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { api } from '../api/client'
|
||||
import { historyAPI } from '../api/history'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { confirmActionResult } from '../components/confirmAction'
|
||||
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
|
||||
import type { Media } from '../types'
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
import { seasonSortOrder } from '../utils/groupSeries'
|
||||
import { episodeNumberValue } from '../utils/episodeNumber'
|
||||
import {
|
||||
episodeProgressById,
|
||||
resolveMediaDetailResume,
|
||||
RESUME_SCAN_LIMIT,
|
||||
} from './mediaDetailResumeModel'
|
||||
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
|
||||
|
||||
type MediaDetailLocationState = { from?: string } | null
|
||||
@@ -26,6 +32,7 @@ interface MediaDetailRefreshParams {
|
||||
setLoading: Dispatch<SetStateAction<boolean>>
|
||||
setEpisodes: Dispatch<SetStateAction<Media[]>>
|
||||
setLoadingEpisodes: Dispatch<SetStateAction<boolean>>
|
||||
setHistory: Dispatch<SetStateAction<HistoryItem[]>>
|
||||
}
|
||||
|
||||
interface MediaDetailActionsParams {
|
||||
@@ -43,6 +50,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [episodes, setEpisodes] = useState<Media[]>([])
|
||||
const [loadingEpisodes, setLoadingEpisodes] = useState(false)
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
|
||||
|
||||
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
|
||||
@@ -57,6 +65,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
setLoading,
|
||||
setEpisodes,
|
||||
setLoadingEpisodes,
|
||||
setHistory,
|
||||
})
|
||||
const location = useLocation()
|
||||
const backFrom = (location.state as MediaDetailLocationState)?.from
|
||||
@@ -108,6 +117,17 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
return (visibleEpisodes.length > 0 ? visibleEpisodes[0] : episodes[0]) || null
|
||||
}, [episodes, visibleEpisodes])
|
||||
|
||||
// 「继续播放」:在「本条目 + 全部剧集(含版本)」里找最近看过的一条。
|
||||
// 详情页的代表条目可能是 OVA/特别篇,所以不能只看它自己的进度。
|
||||
const resume = useMemo(
|
||||
() => resolveMediaDetailResume(media, episodes, history),
|
||||
[episodes, history, media],
|
||||
)
|
||||
const episodeProgress = useMemo(
|
||||
() => episodeProgressById(episodes, history),
|
||||
[episodes, history],
|
||||
)
|
||||
|
||||
const handleMetadataSaved = useCallback(async (next: Media) => {
|
||||
setMedia(next)
|
||||
await refresh()
|
||||
@@ -124,6 +144,8 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
|
||||
visibleEpisodes,
|
||||
hasEpisodes,
|
||||
firstPlayableEpisode,
|
||||
resume,
|
||||
episodeProgress,
|
||||
setSelectedSeason,
|
||||
manualScrapeOpen,
|
||||
metadataEditOpen,
|
||||
@@ -146,20 +168,26 @@ function useMediaDetailRefresh({
|
||||
setLoading,
|
||||
setEpisodes,
|
||||
setLoadingEpisodes,
|
||||
setHistory,
|
||||
}: MediaDetailRefreshParams): () => Promise<void> {
|
||||
return useCallback(async () => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
setLoadingEpisodes(true)
|
||||
try {
|
||||
// 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表
|
||||
// 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。
|
||||
// 四个请求并行发出;详情一到就解锁整页渲染,收藏、分集列表、续播历史
|
||||
// 到达后各自补齐(原先完全串行,首屏要排队等满所有往返)。
|
||||
const nextMediaPromise = mediaAPI.get(id)
|
||||
const favouritePromise = playbackAPI.favouriteStatus(id).catch(() => false)
|
||||
const episodesPromise = mediaAPI
|
||||
.getEpisodes(id)
|
||||
.then((r) => r.items ?? [])
|
||||
.catch(() => [])
|
||||
// 续播扫描只要历史行(include_media=false):服务端不 join media,
|
||||
// 300 条也只有几十 KB。
|
||||
const historyPromise = historyAPI
|
||||
.listRows(RESUME_SCAN_LIMIT)
|
||||
.catch(() => [] as HistoryItem[])
|
||||
|
||||
const nextMedia = await nextMediaPromise
|
||||
setMedia(nextMedia)
|
||||
@@ -169,11 +197,12 @@ function useMediaDetailRefresh({
|
||||
|
||||
const episodes = await episodesPromise
|
||||
setEpisodes(episodes)
|
||||
setHistory(await historyPromise)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingEpisodes(false)
|
||||
}
|
||||
}, [id, setFavourite, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
|
||||
}, [id, setFavourite, setHistory, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
|
||||
}
|
||||
|
||||
function useMediaDetailActions({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { historyAPI } from '../api/history'
|
||||
import type { HistoryItem, Media } from '../types'
|
||||
import {
|
||||
episodeProgressById,
|
||||
resolveMediaDetailResume,
|
||||
RESUME_SCAN_LIMIT,
|
||||
type EpisodeProgress,
|
||||
type MediaDetailResume,
|
||||
} from './mediaDetailResumeModel'
|
||||
|
||||
interface SeriesEpisodeResume {
|
||||
resume: MediaDetailResume | null
|
||||
progressById: Map<string, EpisodeProgress>
|
||||
}
|
||||
|
||||
/**
|
||||
* 剧集面板(媒体库抽屉 / 媒体详情)用的续播数据。
|
||||
*
|
||||
* 代表条目可能是 OVA/特别篇,因此续播目标必须在「整剧所有集」里挑最近看过
|
||||
* 的一条;打开抽屉时扫一次整库最近历史即可,只取历史行(include_media=false)。
|
||||
*/
|
||||
export function useSeriesEpisodeResume(
|
||||
seriesID: string,
|
||||
rep: Media | null,
|
||||
episodes: Media[],
|
||||
): SeriesEpisodeResume {
|
||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!seriesID) {
|
||||
setHistory([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
historyAPI
|
||||
.listRows(RESUME_SCAN_LIMIT)
|
||||
.then((rows) => {
|
||||
if (!cancelled) setHistory(rows)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHistory([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [seriesID])
|
||||
|
||||
const resume = useMemo(
|
||||
() => resolveMediaDetailResume(rep, episodes, history),
|
||||
[episodes, history, rep],
|
||||
)
|
||||
const progressById = useMemo(() => episodeProgressById(episodes, history), [episodes, history])
|
||||
return { resume, progressById }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// 115 云下载(离线下载)相关类型定义(与后端 model.CloudDownloadTask 对应)。
|
||||
|
||||
export type CloudDownloadStatus =
|
||||
| 'queued'
|
||||
| 'submitted'
|
||||
| 'downloading'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'canceled'
|
||||
|
||||
export type CloudDownloadStage =
|
||||
| 'pending'
|
||||
| 'waiting_sync'
|
||||
| 'syncing'
|
||||
| 'scanning'
|
||||
| 'scraping'
|
||||
| 'done'
|
||||
| 'na'
|
||||
| 'failed'
|
||||
|
||||
export type CloudDownloadSourceType = 'magnet' | 'torrent' | 'ed2k' | 'http' | 'ftp'
|
||||
|
||||
export interface CloudDownloadTask {
|
||||
id: string
|
||||
account_id: string
|
||||
sync_path_id: string
|
||||
library_id?: string
|
||||
provider: string
|
||||
source_type: CloudDownloadSourceType
|
||||
source_url: string
|
||||
source_key: string
|
||||
info_hash?: string
|
||||
torrent_name?: string
|
||||
torrent_sha1?: string
|
||||
pick_code?: string
|
||||
wp_path_id: string
|
||||
save_path?: string
|
||||
status: CloudDownloadStatus
|
||||
percent: number
|
||||
size_bytes: number
|
||||
error: string
|
||||
stage: CloudDownloadStage
|
||||
stage_error: string
|
||||
strm_record_id?: string
|
||||
scan_added: number
|
||||
scrape_queued: number
|
||||
retry_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
completed_at?: string | null
|
||||
// 列表接口补充的同步目录信息
|
||||
sync_path_name?: string
|
||||
remote_display_path?: string
|
||||
local_path?: string
|
||||
}
|
||||
|
||||
export interface CloudDownloadList {
|
||||
items: CloudDownloadTask[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface CloudDownloadSubmitItem {
|
||||
task_id?: string
|
||||
source: string
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface CloudDownloadSkipped {
|
||||
raw: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CloudDownloadSubmitResult {
|
||||
accepted: number
|
||||
rejected: number
|
||||
items: CloudDownloadSubmitItem[]
|
||||
skipped?: CloudDownloadSkipped[]
|
||||
}
|
||||
|
||||
export interface CloudDownloadQuotaPackage {
|
||||
surplus: number
|
||||
used: number
|
||||
count: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface CloudDownloadQuota {
|
||||
package?: CloudDownloadQuotaPackage[]
|
||||
count: number
|
||||
surplus: number
|
||||
used: number
|
||||
}
|
||||
Reference in New Issue
Block a user