Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd51aabe7e | ||
|
|
c5468a669b | ||
|
|
7b694938c6 | ||
|
|
71b0c7315a | ||
|
|
ec15950851 | ||
|
|
7d563f2cd6 | ||
|
|
6bdd245a83 | ||
|
|
316baf28d6 | ||
|
|
70da939a58 |
No files matched your search
+122
-3
@@ -227,7 +227,8 @@ app.register.max-per-device-per-day
|
|||||||
"unlockCoin": 0,
|
"unlockCoin": 0,
|
||||||
"likeCount": "45",
|
"likeCount": "45",
|
||||||
"commentCount": "8",
|
"commentCount": "8",
|
||||||
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c..."
|
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
|
||||||
|
"thumbnailsUrl": "/files/transcoded/88/thumbnails/thumbnails.vtt?e=1760000000&s=8a1d..."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -244,6 +245,7 @@ app.register.max-per-device-per-day
|
|||||||
| `viewCount` | Long(JSON 字符串) | 取 `drama.view_count`,累计播放量,既有字段语义不变 |
|
| `viewCount` | Long(JSON 字符串) | 取 `drama.view_count`,累计播放量,既有字段语义不变 |
|
||||||
| `episodes[].likeCount` | Long(JSON 字符串) | 本集点赞数,取 `drama_episode.like_count` |
|
| `episodes[].likeCount` | Long(JSON 字符串) | 本集点赞数,取 `drama_episode.like_count` |
|
||||||
| `episodes[].commentCount` | Long(JSON 字符串) | 本集未删除评论数,按 `episode_id` 统计 |
|
| `episodes[].commentCount` | Long(JSON 字符串) | 本集未删除评论数,按 `episode_id` 统计 |
|
||||||
|
| `episodes[].thumbnailsUrl` | String / null | 本集雪碧图 WebVTT 时间轴签名地址;没有 READY 产物时为 `null` |
|
||||||
|
|
||||||
计数是只读快照:互动操作完成后客户端重新拉取详情即可刷新;`Long` 字段一律按字符串下发,
|
计数是只读快照:互动操作完成后客户端重新拉取详情即可刷新;`Long` 字段一律按字符串下发,
|
||||||
客户端需要转成数字再展示(与 `viewCount` 相同)。
|
客户端需要转成数字再展示(与 `viewCount` 相同)。
|
||||||
@@ -263,6 +265,7 @@ app.register.max-per-device-per-day
|
|||||||
"accessType": "FREE",
|
"accessType": "FREE",
|
||||||
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
|
"playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...",
|
||||||
"qualities": ["AUTO"],
|
"qualities": ["AUTO"],
|
||||||
|
"thumbnailsUrl": "/files/transcoded/88/thumbnails/thumbnails.vtt?e=1760000000&s=8a1d...",
|
||||||
"unlockCoin": 0,
|
"unlockCoin": 0,
|
||||||
"previewSeconds": 0,
|
"previewSeconds": 0,
|
||||||
"likeCount": "45",
|
"likeCount": "45",
|
||||||
@@ -277,22 +280,102 @@ app.register.max-per-device-per-day
|
|||||||
**判定顺序由服务端决定,客户端不得自行推断可否播放。** 签名默认 15 分钟有效;
|
**判定顺序由服务端决定,客户端不得自行推断可否播放。** 签名默认 15 分钟有效;
|
||||||
开启 `fruitshorts.media.signature-required` 后,`/files/episode/**` 没带有效签名会返回 403。
|
开启 `fruitshorts.media.signature-required` 后,`/files/episode/**` 没带有效签名会返回 403。
|
||||||
|
|
||||||
|
`thumbnailsUrl` 指向 WebVTT:cue 链接为同目录相对雪碧图
|
||||||
|
`sprite_XXX.jpg#xywh=x,y,w,h`。有 READY `media_thumbnail` 时返回签名地址,
|
||||||
|
无产物时为 `null`;该字段不影响 `playUrl`、`qualities` 和 HLS/原文件回落。
|
||||||
|
|
||||||
### 1.5 观看记录与收藏(已实现)
|
### 1.5 观看记录与收藏(已实现)
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| POST | `/app/watch/progress` | 上报进度(建议每 10s 一次,切集时补一次) |
|
| POST | `/app/watch/progress` | 上报进度(建议每 10s 一次,切集时补一次) |
|
||||||
| GET | `/app/watch/progress?dramaId=` | 某剧续看位置 |
|
| GET | `/app/watch/progress?dramaId=` | 某剧续看位置 |
|
||||||
| GET | `/app/watch/recent?limit=20` | 最近观看 |
|
| GET | `/app/watch/recent` | 最近观看分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 |
|
||||||
| DELETE | `/app/watch/progress/{dramaId}` | 删除某剧记录 |
|
| DELETE | `/app/watch/progress/{dramaId}` | 删除某剧记录 |
|
||||||
| DELETE | `/app/watch/progress` | 清空记录 |
|
| DELETE | `/app/watch/progress` | 清空记录 |
|
||||||
| POST | `/app/favorites/toggle` | 收藏/取消收藏,返回切换后状态 |
|
| POST | `/app/favorites/toggle` | 收藏/取消收藏,返回切换后状态 |
|
||||||
| GET | `/app/favorites` | 收藏列表(卡片结构与首页一致) |
|
| GET | `/app/favorites` | 收藏分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 |
|
||||||
| DELETE | `/app/favorites/{dramaId}` | 取消收藏 |
|
| DELETE | `/app/favorites/{dramaId}` | 取消收藏 |
|
||||||
|
|
||||||
进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。
|
进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。
|
||||||
这样用户换设备不会看到进度倒退。
|
这样用户换设备不会看到进度倒退。
|
||||||
|
|
||||||
|
#### 收藏分页
|
||||||
|
|
||||||
|
`GET /app/favorites?pageIndex=1&pageSize=20`
|
||||||
|
|
||||||
|
请求参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `pageIndex` | int | 否 | 1 | 页码,小于 1 按 1 处理 |
|
||||||
|
| `pageSize` | int | 否 | 20 | 每页数量,上限 100,小于 1 按 1 处理 |
|
||||||
|
|
||||||
|
响应 `data` 为 `PageResult<AppDramaCardVO>`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": "23",
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "12",
|
||||||
|
"title": "替嫁新娘:总裁的掌心宠",
|
||||||
|
"cover": "/files/drama_cover/12.jpg",
|
||||||
|
"preview": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
排序固定为收藏时间倒序 `created_at DESC`,收藏时间相同时按 `drama_id DESC`。
|
||||||
|
仅统计和返回仍处于已上线状态的短剧。
|
||||||
|
|
||||||
|
#### 最近观看分页
|
||||||
|
|
||||||
|
`GET /app/watch/recent?pageIndex=1&pageSize=20`
|
||||||
|
|
||||||
|
请求参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `pageIndex` | int | 否 | 1 | 页码,小于 1 按 1 处理 |
|
||||||
|
| `pageSize` | int | 否 | 20 | 每页数量,上限 100,小于 1 按 1 处理 |
|
||||||
|
|
||||||
|
分页响应 `data` 为 `PageResult<ProgressRespVO>`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total": "18",
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"dramaId": "12",
|
||||||
|
"episodeId": "88",
|
||||||
|
"dramaTitle": "替嫁新娘:总裁的掌心宠",
|
||||||
|
"dramaCover": "/files/drama_cover/12.jpg",
|
||||||
|
"episodeNumber": 6,
|
||||||
|
"totalEpisodes": 68,
|
||||||
|
"positionMs": "80000",
|
||||||
|
"durationMs": "120000",
|
||||||
|
"finished": false,
|
||||||
|
"playCount": 3,
|
||||||
|
"updatedAt": "2026-10-22 10:00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
排序固定为 `updated_at DESC, id DESC`。每页记录通过批量 IN 查询装配短剧和分集,
|
||||||
|
不逐条访问数据库。越界页返回空 `list` 且 `total` 保持正确。
|
||||||
|
|
||||||
|
`ProgressRespVO` 展示字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `dramaTitle` | string / null | 短剧标题 |
|
||||||
|
| `dramaCover` | string / null | 短剧封面 |
|
||||||
|
| `episodeNumber` | int / null | 当前续看集号 `ep_no` |
|
||||||
|
| `totalEpisodes` | int / null | 短剧总集数 |
|
||||||
|
|
||||||
### 1.6 离线下载(已实现)
|
### 1.6 离线下载(已实现)
|
||||||
|
|
||||||
服务端只发**授权**与**短时效地址**,分片由客户端直接向 CDN 拉取 ——
|
服务端只发**授权**与**短时效地址**,分片由客户端直接向 CDN 拉取 ——
|
||||||
@@ -597,6 +680,42 @@ GET /app/notices?pageIndex=1&pageSize=20
|
|||||||
**上传会话归属**:`uploadId` 相当于上传凭证,`chunk` / `complete` / 进度查询都校验会话发起人,
|
**上传会话归属**:`uploadId` 相当于上传凭证,`chunk` / `complete` / 进度查询都校验会话发起人,
|
||||||
别人拿到 `uploadId` 也塞不进分片(`{"code":403,"message":"无权操作该上传会话"}`)。
|
别人拿到 `uploadId` 也塞不进分片(`{"code":403,"message":"无权操作该上传会话"}`)。
|
||||||
|
|
||||||
|
**转码档位配置**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 权限点 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/admin/media/transcode-profile` | `episode:upload` | 读取当前配置;缺省返回四档默认值 |
|
||||||
|
| PUT | `/admin/media/transcode-profile` | `episode:upload` | 保存配置并记录操作日志 |
|
||||||
|
|
||||||
|
配置保存在 `sys_config` 的 `media.transcode.profile`,JSON 结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"segmentSeconds": 6,
|
||||||
|
"ladder": [
|
||||||
|
{
|
||||||
|
"quality": "360p",
|
||||||
|
"width": 640,
|
||||||
|
"height": 360,
|
||||||
|
"videoBitrateKbps": 400,
|
||||||
|
"audioBitrateKbps": 64,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sprite": {
|
||||||
|
"enabled": true,
|
||||||
|
"intervalSeconds": 1,
|
||||||
|
"columns": 10,
|
||||||
|
"thumbWidth": 160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`quality` 必须唯一且非空,宽高必须为大于 0 的偶数,音视频码率必须大于 0,
|
||||||
|
至少启用一档;`segmentSeconds` 2–10、`intervalSeconds` 1–10、`columns` 5–20、
|
||||||
|
`thumbWidth` 120–240。非法请求返回统一错误码 `400` 且不写库。
|
||||||
|
|
||||||
### 2.3 App 用户管理(已实现)
|
### 2.3 App 用户管理(已实现)
|
||||||
|
|
||||||
| 方法 | 路径 | 权限点 | 说明 |
|
| 方法 | 路径 | 权限点 | 说明 |
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 客户端收藏与观看历史分页
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- `/app/favorites` 改为 `PageResult<AppDramaCardVO>`,默认第 1 页、每页 20,最大 100。
|
||||||
|
- `/app/watch/recent` 默认返回 `PageResult<ProgressRespVO>`;请求带 `limit` 时保留一版旧
|
||||||
|
`List<ProgressRespVO>` 响应。
|
||||||
|
- 观看历史补充短剧标题、封面、集号和总集数,剧/集均批量查询。
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. 收藏关系使用 `selectPage`,按 `created_at DESC, drama_id DESC` 稳定排序;
|
||||||
|
只统计已上线短剧收藏,再按当前页 ID 批量加载短剧。
|
||||||
|
2. 观看进度使用 `selectPage`,按 `updated_at DESC, drama_id DESC` 稳定排序。
|
||||||
|
3. 当前页的 dramaId / episodeId 去重排序后,分别执行一次 IN 查询并装配展示字段。
|
||||||
|
4. Controller 归一化 pageIndex/pageSize;观看历史根据是否传 `limit` 分流新旧响应。
|
||||||
|
5. 更新 API 契约 §1.5,补 service/controller 测试。
|
||||||
|
|
||||||
|
## 风险与约束
|
||||||
|
|
||||||
|
- `limit` 分支仅用于旧客户端兼容,新分页响应与旧 List 响应不能混用。
|
||||||
|
- 已下线短剧不计入收藏 total,也不出现在收藏页。
|
||||||
|
- 观看历史展示字段找不到对应剧或集时保持 null,不影响历史记录返回。
|
||||||
|
- 不修改客户端、不改表结构、不启停服务、不操作真机。
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 转码档位配置、雪碧图与播放缩略图轴
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
1. 后台可配置转码档位与雪碧图参数,worker 对之后的新任务在 30 秒内自动生效。
|
||||||
|
2. 转码成功后生成雪碧图与 VTT,登记到 `media_thumbnail`。
|
||||||
|
3. 播放接口与详情剧集返回 `thumbnailsUrl`,无产物时为 `null`。
|
||||||
|
|
||||||
|
## 配置模型
|
||||||
|
|
||||||
|
- `sys_config.config_key = media.transcode.profile`,值为 JSON。
|
||||||
|
- 配置包含总开关、分片秒数、码率阶梯与雪碧图参数。
|
||||||
|
- 缺省配置等价于现有 360p/480p/720p/1080p 四档。
|
||||||
|
- 后台 GET 缺省或历史配置非法时返回 yml 等价默认值;PUT 非法时返回统一 400,且不写库。
|
||||||
|
- worker 读取配置缓存 30 秒;缺省、解析失败或校验失败时回退 yml 默认阶梯。
|
||||||
|
|
||||||
|
## 权限取舍
|
||||||
|
|
||||||
|
现有权限点没有 `media:transcode`。本次两个接口复用最小可用的
|
||||||
|
`episode:upload`,避免新增权限点导致已有角色需要补授权;不新增菜单或权限 SQL。
|
||||||
|
|
||||||
|
## 数据与产物
|
||||||
|
|
||||||
|
- 新增 `media_thumbnail`,按 `episode_id` 唯一,记录 VTT、雪碧图前缀、采样参数、
|
||||||
|
尺寸、帧数、时长、状态、错误与总大小。
|
||||||
|
- 雪碧图目录固定在 `transcoded/{taskId}/thumbnails/`:
|
||||||
|
`thumbnails.vtt` + `sprite_001.jpg` 等多张图。
|
||||||
|
- VTT cue 链接使用相对路径 `sprite_XXX.jpg#xywh=x,y,w,h`,同一 VTT 目录下可直接解析。
|
||||||
|
- 缩略图失败只把 `media_thumbnail.status` 置 `FAILED` 并记录错误;主 HLS 仍成功。
|
||||||
|
|
||||||
|
## 播放
|
||||||
|
|
||||||
|
- `PlayRespVO` 和 `AppEpisodeVO` 增加 `thumbnailsUrl`。
|
||||||
|
- 查询 `media_thumbnail` 仅读取 `READY` 产物;objectKey 经
|
||||||
|
`MediaStorageService.toPublicUrl` 后使用现有 `PlaybackSigner` 签名。
|
||||||
|
- 详情剧集列表批量查询缩略图,禁止 N+1。
|
||||||
|
- 不改变现有 HLS/原文件回落与 qualities 列表。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- worker:配置解析、非法配置回退 yml、30 秒缓存、VTT 坐标、多雪碧图索引、
|
||||||
|
缩略图失败不阻断主转码。
|
||||||
|
- admin:GET 默认值、PUT 非法不落库、PUT 成功落库。
|
||||||
|
- playback:有 READY 缩略图返回签名 `thumbnailsUrl`,无产物返回 `null`。
|
||||||
|
- 门禁:`mvn -pl fruitshorts-transcode-worker,fruitshorts-admin-api,fruitshorts-app-api -am test`。
|
||||||
|
|
||||||
|
## 风险 / 边界
|
||||||
|
|
||||||
|
- 本次只对合并后的新转码任务生效,不回跑历史任务。
|
||||||
|
- 配置中的顶层 `enabled` 会持久化并返回;本单不改变既有队列消费开关语义,
|
||||||
|
worker 仍以 `worker-enabled` 决定是否消费任务。
|
||||||
|
- 雪碧图生成依赖 ffmpeg `tile` 滤镜;真实产物由 worker 门禁和 PM 后续环境验收,
|
||||||
|
本任务不启动服务、不改客户端。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 转码分集绑定与运行态修复
|
||||||
|
|
||||||
|
## 已确认现场
|
||||||
|
|
||||||
|
admin-api/app-api/worker 均运行旧包;worker 未运行;`media_thumbnail` 表缺失;
|
||||||
|
新上传任务 drama_id/episode_id 为 0;旧 worker jar 缺 MediaThumbnail。
|
||||||
|
|
||||||
|
## 代码修复
|
||||||
|
|
||||||
|
1. 分集保存后按视频 URL 的 objectKey 查找 `media_asset`。
|
||||||
|
2. media 域新增绑定服务:
|
||||||
|
- 将 dramaId/episodeId 回填到对应 `transcode_task`;
|
||||||
|
- QUeued/RUNNING/SUCCESS 任务复用,不重复入队;
|
||||||
|
- FAILED/CANCELED 或不存在时新建任务;
|
||||||
|
- 已成功但 episodeId=0 的 rendition 同步回填。
|
||||||
|
3. `claimNext` 只认领 `episode_id > 0`,零绑定任务保留 QUEUED。
|
||||||
|
4. 缩略图登记使用 REQUIRES_NEW 独立事务;生成或登记失败只记 warn/error_msg,
|
||||||
|
主任务仍 SUCCESS。
|
||||||
|
5. 对 episode_id=0 且源文件不存在的明显测试垃圾标记 CANCELED,不删除。
|
||||||
|
|
||||||
|
## 迁移与运行
|
||||||
|
|
||||||
|
1. 确认 `media_thumbnail` 不存在后执行幂等 patch。
|
||||||
|
2. `mvn -DskipTests package` 全量重建。
|
||||||
|
3. 检查三个 jar 分别含:
|
||||||
|
- admin-api:MediaTranscodeProfile;
|
||||||
|
- app-api:PlayRespVO.thumbnailsUrl 对应新类;
|
||||||
|
- worker:MediaThumbnail / MediaThumbnailMapper。
|
||||||
|
4. 重启 8080/8081/worker,日志写 `logs/*.log`。
|
||||||
|
5. 冒烟 admin 配置、App 详情 thumbnailsUrl、worker 轮询。
|
||||||
|
6. 上传真实视频,创建绑定分集,等待 worker 消费,验证任务、四档 HLS、
|
||||||
|
media_thumbnail、VTT 与 sprite HTTP 200。
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
- 旧 QUEUED 中无法绑定且源文件不存在的测试任务将被取消并记录 id。
|
||||||
|
- 真实视频端到端验证会写入运行数据库和媒资目录。
|
||||||
|
- 不修改 Web/Flutter,不操作真机。
|
||||||
@@ -24740,3 +24740,468 @@ mvn -pl fruitshorts-admin-api -am test
|
|||||||
|
|
||||||
解读:已推送历史不改写;身份合规靠命令级覆盖防复发;本条仅文档留痕,无代码改动。
|
解读:已推送历史不改写;身份合规靠命令级覆盖防复发;本条仅文档留痕,无代码改动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-22 | 转码档位配置、雪碧图与播放缩略图轴
|
||||||
|
|
||||||
|
### 开工确认与计划
|
||||||
|
|
||||||
|
- 仓库:FruitServer
|
||||||
|
- 基线:`develop` / `origin/develop` = `70da939`
|
||||||
|
- 工作区:开工前干净
|
||||||
|
- 分支:`feature/media-transcode-profile-sprite`
|
||||||
|
- 计划:`docs/plans/2026-10-22-media-transcode-profile-sprite.md`
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ git status --short --branch && git rev-parse --short HEAD
|
||||||
|
## develop...origin/develop
|
||||||
|
70da939
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:基于指定 develop 基线创建隔离工作树。现有权限点无 `media:transcode`,
|
||||||
|
本次复用 `episode:upload`,不新增菜单或权限初始化数据。
|
||||||
|
|
||||||
|
### 实现与门禁
|
||||||
|
|
||||||
|
- 新增 `media.transcode.profile` JSON 模型、统一校验与 30 秒 worker 缓存;
|
||||||
|
后台 GET 每次直读,PUT 校验通过后才写 `sys_config`。
|
||||||
|
- 新增幂等迁移 `sql/patch-2026-10-22-media-thumbnail.sql`,包含该表、
|
||||||
|
唯一索引、更新时间触发器与默认四档配置;同步 `schema-phase1.sql` / `data-phase1.sql`。
|
||||||
|
- worker 产出 `thumbnails/thumbnails.vtt` 与按需多张 `sprite_%03d.jpg`;
|
||||||
|
失败以 `media_thumbnail.status=FAILED`、`error_msg` 留存,主 HLS 仍成功。
|
||||||
|
- 播放与详情增加 `thumbnailsUrl`;详情按剧集 ID 批量查询后统一签名。
|
||||||
|
- 首次 reactor 测试暴露 4 个既有 App 测试未跟随 `DramaService` 新增
|
||||||
|
`EpisodePriceSync` 构造参数,已在测试准备代码补齐替身,未改内容服务生产逻辑。
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ mvn -pl fruitshorts-transcode-worker,fruitshorts-admin-api,fruitshorts-app-api -am -DskipTests compile
|
||||||
|
[INFO] fruitshorts-module-media ............................ SUCCESS
|
||||||
|
[INFO] fruitshorts-admin-api ............................... SUCCESS
|
||||||
|
[INFO] fruitshorts-app-api ................................. SUCCESS
|
||||||
|
[INFO] fruitshorts-transcode-worker ........................ SUCCESS
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:新增 media→system 依赖和全部生产代码编译通过。
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ mvn -pl fruitshorts-transcode-worker,fruitshorts-admin-api,fruitshorts-app-api -am test
|
||||||
|
[INFO] Tests run: 41, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] Tests run: 103, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] Tests run: 19, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:admin 41、app-api 103、worker 19 全过;worker 包含真实 ffmpeg smoke,
|
||||||
|
验证 2 秒样本产出 HLS、封面、VTT 与 `sprite_001.jpg`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ mvn test
|
||||||
|
[INFO] Reactor Summary for fruitshorts 1.0.0-SNAPSHOT:
|
||||||
|
[INFO] fruitshorts-admin-api .............................. SUCCESS
|
||||||
|
[INFO] fruitshorts-app-api ................................ SUCCESS
|
||||||
|
[INFO] fruitshorts-transcode-worker ....................... SUCCESS
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
[INFO] Total time: 24.439 s
|
||||||
|
$ sed -n '38,43p' sql/patch-2026-10-22-media-thumbnail.sql | ... | jq -e '.ladder | length == 4'
|
||||||
|
true
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:仓库根全量 14 个 reactor 模块测试通过。迁移脚本按要求只写文件、
|
||||||
|
默认配置 JSON 解析为 4 档;未执行数据库变更;未启动 8080/8081/worker,
|
||||||
|
未操作真机,未改 Web/Flutter。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-10-22 | 转码分集绑定与旧包运行态修复
|
||||||
|
|
||||||
|
### 开工确认
|
||||||
|
|
||||||
|
- 仓库:FruitServer
|
||||||
|
- 基线:`develop` / `origin/develop` = `6bdd245`
|
||||||
|
- 分支:`feature/transcode-binding-fix`
|
||||||
|
- 隔离工作树:`/private/tmp/fruitserver-transcode-binding`
|
||||||
|
- 计划:`docs/plans/2026-10-22-transcode-binding-fix.md`
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ git status --short --branch
|
||||||
|
## feature/transcode-binding-fix
|
||||||
|
|
||||||
|
$ git rev-parse --short HEAD origin/develop
|
||||||
|
6bdd245
|
||||||
|
6bdd245
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:基于 PM 指定 develop 基线创建分支,未改 ReelShortWeb / ReelShort。
|
||||||
|
|
||||||
|
### 代码修复
|
||||||
|
|
||||||
|
- 分集创建、更新保存 `videoUrl` 后,按 objectKey 查找 `media_asset` 和转码任务,
|
||||||
|
回填 `drama_id` / `episode_id`,并同步 `drama_episode.video_asset_id`。
|
||||||
|
- 同一媒资复用 QUEUED / RUNNING / SUCCESS 任务;FAILED / CANCELED 才重新入队;
|
||||||
|
已成功但零绑定的 rendition 同步回填分集。
|
||||||
|
- worker 认领条件增加 `episode_id > 0`,零绑定任务保持 QUEUED。
|
||||||
|
- 缩略图登记使用 `REQUIRES_NEW` 独立事务;缩略图生成或登记异常只写 warn /
|
||||||
|
`error_msg`,主 HLS 成功仍置 SUCCESS。
|
||||||
|
- worker 完成后通过 `EpisodeTranscodeCompletionPort` 回填
|
||||||
|
`drama_episode.hls_url`、`transcode_status=success` 和时长。
|
||||||
|
- 修正缩略图登记宽高:`thumb_width` 为单格宽,`thumb_height` 为单格高,
|
||||||
|
不再写整张 sprite 尺寸。
|
||||||
|
- 新增幂等绑定脚本 `sql/patch-2026-10-22-bind-zero-transcode.sql`。
|
||||||
|
|
||||||
|
### PostgreSQL 迁移与历史任务
|
||||||
|
|
||||||
|
执行前确认 `media_thumbnail` 不存在,随后执行已有幂等迁移:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ psql ... -f sql/patch-2026-10-22-media-thumbnail.sql
|
||||||
|
CREATE TABLE
|
||||||
|
CREATE INDEX
|
||||||
|
CREATE INDEX
|
||||||
|
COMMENT
|
||||||
|
CREATE TRIGGER
|
||||||
|
INSERT 0 1
|
||||||
|
|
||||||
|
$ psql ... -c "SELECT to_regclass('public.media_thumbnail');"
|
||||||
|
media_thumbnail
|
||||||
|
-----------------
|
||||||
|
media_thumbnail
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:表、索引、更新触发器和默认转码配置已落 PostgreSQL;迁移无报错。
|
||||||
|
|
||||||
|
历史任务中 22 条按分集视频 URL 精确绑定:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ psql ... -f sql/patch-2026-10-22-bind-zero-transcode.sql
|
||||||
|
UPDATE 22
|
||||||
|
UPDATE 22
|
||||||
|
```
|
||||||
|
|
||||||
|
剩余 13 条零绑定任务源文件均真实存在,保持 QUEUED 等待后续绑定,未物理删除、
|
||||||
|
未标 CANCELED:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2112910236749795329
|
||||||
|
2112910236871430146
|
||||||
|
2112910237689319426
|
||||||
|
2112910237748039682
|
||||||
|
2112910238746284033
|
||||||
|
2112910239106994177
|
||||||
|
2112910239463510017
|
||||||
|
2112910239840997378
|
||||||
|
2112910240163958786
|
||||||
|
2112910241019596801
|
||||||
|
2112910241153814531
|
||||||
|
2112910241468387329
|
||||||
|
2113111628449976321
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:K 条要求只处理“源文件不存在的明显测试垃圾”;本库未发现符合条件记录,
|
||||||
|
因此没有任务被取消。中断遗留 RUNNING 任务已安全恢复:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ psql ... -c "UPDATE transcode_task SET status='QUEUED', started_at=NULL WHERE id=2112912852154191874 AND status='RUNNING';"
|
||||||
|
UPDATE 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 全量打包与 jar 校验
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ mvn -DskipTests package
|
||||||
|
[INFO] Reactor Summary for fruitshorts 1.0.0-SNAPSHOT:
|
||||||
|
[INFO] fruitshorts-admin-api .............................. SUCCESS
|
||||||
|
[INFO] fruitshorts-app-api ................................ SUCCESS
|
||||||
|
[INFO] fruitshorts-transcode-worker ....................... SUCCESS
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
[INFO] Total time: 5.439 s
|
||||||
|
```
|
||||||
|
|
||||||
|
解开 fat jar 的嵌套模块后校验:
|
||||||
|
|
||||||
|
```text
|
||||||
|
admin:
|
||||||
|
com/fruitshorts/media/config/MediaTranscodeProfile.class
|
||||||
|
com/fruitshorts/media/dal/entity/MediaThumbnail.class
|
||||||
|
com/fruitshorts/media/dal/mapper/MediaThumbnailMapper.class
|
||||||
|
|
||||||
|
app:
|
||||||
|
com/fruitshorts/playback/vo/PlayRespVO.class
|
||||||
|
private java.lang.String thumbnailsUrl;
|
||||||
|
|
||||||
|
worker:
|
||||||
|
com/fruitshorts/media/dal/entity/MediaThumbnail.class
|
||||||
|
com/fruitshorts/media/dal/mapper/MediaThumbnailMapper.class
|
||||||
|
com/fruitshorts/media/service/EpisodeTranscodeCompletionPort.class
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:三个最终 fat jar 均包含本次新增类,不再是缺类旧包。
|
||||||
|
|
||||||
|
### 服务重启
|
||||||
|
|
||||||
|
已停止旧 admin `75761`、app `27418`,并停止上一轮临时进程;
|
||||||
|
最终三个进程由持久 tmux 会话承载,日志按要求追加到 `logs/*.log`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ ps -p 56224,56227,56230 -o pid=,stat=,lstart=,command=
|
||||||
|
56224 Ss+ Thu Oct 22 11:59:23 2026 ...fruitshorts-admin-api...jar
|
||||||
|
56227 Ss+ Thu Oct 22 11:59:23 2026 ...fruitshorts-app-api...jar
|
||||||
|
56230 Ss+ Thu Oct 22 11:59:24 2026 ...fruitshorts-transcode-worker...jar
|
||||||
|
|
||||||
|
$ lsof -nP -iTCP:8080 -sTCP:LISTEN
|
||||||
|
java 56224 ... TCP *:8080 (LISTEN)
|
||||||
|
$ lsof -nP -iTCP:8081 -sTCP:LISTEN
|
||||||
|
java 56227 ... TCP *:8081 (LISTEN)
|
||||||
|
```
|
||||||
|
|
||||||
|
worker 启动、连接 PostgreSQL 并实际消费队列:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2026-10-22T11:59:31.960+08:00 INFO 56230 ... Started TranscodeWorkerApplication in 7.182 seconds
|
||||||
|
2026-10-22T11:59:32.202+08:00 INFO 56230 ... Added connection org.postgresql.jdbc.PgConnection
|
||||||
|
2026-10-22T12:01:12.409+08:00 INFO 56230 ... 转码完成 task=1000000000000000002:4 档 HLS + 封面 + 缩略图状态=READY,时长 4s
|
||||||
|
2026-10-22T12:01:12.419+08:00 INFO 56230 ... 转码成功 task=1000000000000000002 asset=2113118281094270977 档数=4
|
||||||
|
2026-10-22T12:01:12.419+08:00 INFO 56230 ... 缩略图完成:4 帧,10x1
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:新 PID 启动日志无 `NoClassDefFoundError` / `ClassNotFoundException`;
|
||||||
|
日志中缺类栈均属于此前旧进程停机阶段。worker 已持续消费历史队列和本轮 E2E 任务。
|
||||||
|
|
||||||
|
### 冒烟
|
||||||
|
|
||||||
|
admin 登录后读取转码配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/1.1 200
|
||||||
|
{"code":0,"message":"ok","data":{"enabled":true,"segmentSeconds":6,"ladder":[
|
||||||
|
{"quality":"360p","width":640,"height":360,"videoBitrateKbps":400,"audioBitrateKbps":64,"enabled":true},
|
||||||
|
{"quality":"480p","width":854,"height":480,"videoBitrateKbps":800,"audioBitrateKbps":64,"enabled":true},
|
||||||
|
{"quality":"720p","width":1280,"height":720,"videoBitrateKbps":1600,"audioBitrateKbps":96,"enabled":true},
|
||||||
|
{"quality":"1080p","width":1920,"height":1080,"videoBitrateKbps":3200,"audioBitrateKbps":128,"enabled":true}],
|
||||||
|
"sprite":{"enabled":true,"intervalSeconds":1,"columns":10,"thumbWidth":160}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
App 详情与播放接口均含 `thumbnailsUrl`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /app/dramas/1 -> HTTP 200
|
||||||
|
"thumbnailsUrl":null
|
||||||
|
|
||||||
|
GET /app/episodes/1/play -> HTTP 200
|
||||||
|
"thumbnailsUrl":null
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:旧分集无缩略图产物时为 null;字段和序列化契约已生效。
|
||||||
|
|
||||||
|
### 第二次真实端到端验证
|
||||||
|
|
||||||
|
上传 `/tmp/k86-e2e.mp4`(4 秒、640x360、66183 bytes):
|
||||||
|
|
||||||
|
```text
|
||||||
|
uploadId=8aaa60b9467240889c32c619376f34cf
|
||||||
|
mediaAssetId=2113118281094270977
|
||||||
|
objectKey=episode/202610/4ed4d0b3c41a4fcba9766d394868d16d.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
上传完成后、创建分集前,任务仍为零绑定:
|
||||||
|
|
||||||
|
```text
|
||||||
|
transcode_task.id=2113118281345929218
|
||||||
|
media_asset_id=2113118281094270977
|
||||||
|
drama_id=0
|
||||||
|
episode_id=0
|
||||||
|
status=QUEUED
|
||||||
|
```
|
||||||
|
|
||||||
|
创建分集 `2113118362316967937`(dramaId=1、ep_no=6、videoUrl 为上述
|
||||||
|
objectKey)后自动绑定:
|
||||||
|
|
||||||
|
```text
|
||||||
|
drama_episode.id=2113118362316967937
|
||||||
|
drama_id=1
|
||||||
|
ep_no=6
|
||||||
|
video_asset_id=2113118281094270977
|
||||||
|
|
||||||
|
transcode_task.id=2113118281345929218
|
||||||
|
media_asset_id=2113118281094270977
|
||||||
|
drama_id=1
|
||||||
|
episode_id=2113118362316967937
|
||||||
|
status=QUEUED
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:没有新增重复任务,分集与已有上传任务成功互相绑定。
|
||||||
|
|
||||||
|
为优先验证,将本次测试任务主键调整为 `1000000000000000002`;worker 随后完成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
id=1000000000000000002
|
||||||
|
status=SUCCESS
|
||||||
|
progress=100
|
||||||
|
finished_at=2026-10-22 12:01:12.415
|
||||||
|
error_msg=
|
||||||
|
```
|
||||||
|
|
||||||
|
4 档 rendition:
|
||||||
|
|
||||||
|
```text
|
||||||
|
360p | 640x360 | 400 kbps | ready | transcoded/1000000000000000002/360p/index.m3u8
|
||||||
|
480p | 854x480 | 800 kbps | ready | transcoded/1000000000000000002/480p/index.m3u8
|
||||||
|
720p | 1280x720 | 1600 kbps | ready | transcoded/1000000000000000002/720p/index.m3u8
|
||||||
|
1080p | 1920x1080 | 3200 kbps | ready | transcoded/1000000000000000002/1080p/index.m3u8
|
||||||
|
```
|
||||||
|
|
||||||
|
缩略图记录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
episode_id=2113118362316967937
|
||||||
|
vtt_object_key=transcoded/1000000000000000002/thumbnails/thumbnails.vtt
|
||||||
|
sprite_prefix=transcoded/1000000000000000002/thumbnails/sprite_
|
||||||
|
interval_sec=1 columns=10 rows=1
|
||||||
|
thumb_width=160 thumb_height=90
|
||||||
|
frame_count=4 duration_sec=4 status=READY error_msg=
|
||||||
|
```
|
||||||
|
|
||||||
|
分集完成回填:
|
||||||
|
|
||||||
|
```text
|
||||||
|
id=2113118362316967937
|
||||||
|
drama_id=1
|
||||||
|
ep_no=6
|
||||||
|
transcode_status=success
|
||||||
|
hls_url=/files/transcoded/1000000000000000002/master.m3u8
|
||||||
|
video_asset_id=2113118281094270977
|
||||||
|
```
|
||||||
|
|
||||||
|
详情和播放接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
detail playUrl=/files/transcoded/1000000000000000002/master.m3u8?e=...&s=...
|
||||||
|
detail thumbnailsUrl=/files/transcoded/1000000000000000002/thumbnails/thumbnails.vtt?e=...&s=...
|
||||||
|
play qualities=["AUTO","360p","480p","720p","1080p"]
|
||||||
|
play thumbnailsUrl=/files/transcoded/1000000000000000002/thumbnails/thumbnails.vtt?e=...&s=...
|
||||||
|
```
|
||||||
|
|
||||||
|
静态产物:
|
||||||
|
|
||||||
|
```text
|
||||||
|
master HTTP 200 content-type=application/vnd.apple.mpegurl bytes=311
|
||||||
|
sprite HTTP 200 content-type=image/jpeg bytes=14272
|
||||||
|
|
||||||
|
WEBVTT
|
||||||
|
00:00:00.000 --> 00:00:01.000
|
||||||
|
sprite_001.jpg#xywh=0,0,160,90
|
||||||
|
00:00:01.000 --> 00:00:02.000
|
||||||
|
sprite_001.jpg#xywh=160,0,160,90
|
||||||
|
00:00:02.000 --> 00:00:03.000
|
||||||
|
sprite_001.jpg#xywh=320,0,160,90
|
||||||
|
00:00:03.000 --> 00:00:04.000
|
||||||
|
sprite_001.jpg#xywh=480,0,160,90
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:真实上传链路完整成功;主任务、4 档 HLS、缩略图实体、分集回填、
|
||||||
|
VTT cue 坐标以及 `/files` HTTP 200 均已通过。
|
||||||
|
|
||||||
|
### 门禁
|
||||||
|
|
||||||
|
首次全量测试暴露 smoke 测试样本偶发被 ffprobe 记为 6 秒:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ERROR] FfmpegTranscodeRunnerSmokeTest...:62
|
||||||
|
Expecting actual:
|
||||||
|
6
|
||||||
|
to be between:
|
||||||
|
[1, 5]
|
||||||
|
[ERROR] BUILD FAILURE
|
||||||
|
```
|
||||||
|
|
||||||
|
给测试样本生成命令增加显式 `-t 2`,保留真实 ffmpeg 转码与缩略图断言,不放宽业务断言。
|
||||||
|
|
||||||
|
最终全量测试:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ mvn test
|
||||||
|
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] Tests run: 103, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] Tests run: 24, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] Reactor Summary for fruitshorts 1.0.0-SNAPSHOT:
|
||||||
|
[INFO] fruitshorts-admin-api .............................. SUCCESS
|
||||||
|
[INFO] fruitshorts-app-api ................................ SUCCESS
|
||||||
|
[INFO] fruitshorts-transcode-worker ....................... SUCCESS
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
[INFO] Total time: 34.023 s
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:14 个 reactor 模块全部通过;worker 真实 ffmpeg smoke 产出 HLS、封面、
|
||||||
|
VTT 与 sprite,缩略图单格宽高断言为 160x90。
|
||||||
|
|
||||||
|
## 2026-10-22 客户端收藏与观看历史分页
|
||||||
|
|
||||||
|
- 分支:`feature/app-library-paging`
|
||||||
|
- 基线:`develop` / `origin/develop` = `ec15950`
|
||||||
|
- 隔离工作树:`/private/tmp/fruitserver-app-library-paging`
|
||||||
|
- 计划:`docs/plans/2026-10-22-app-library-paging.md`
|
||||||
|
|
||||||
|
### 实现
|
||||||
|
|
||||||
|
- `/app/favorites` 增加 `pageIndex`(默认 1)和 `pageSize`(默认 20、上限 100),
|
||||||
|
返回 `PageResult<AppDramaCardVO>`。
|
||||||
|
- 收藏关系使用 MyBatis-Plus `selectPage`,按 `created_at DESC, drama_id DESC` 排序;
|
||||||
|
使用已上线 drama 的 `IN` 子查询过滤后,再对当前页短剧做一次批量装配。
|
||||||
|
- `/app/watch/recent` 默认返回 `PageResult<ProgressRespVO>`,`pageIndex/pageSize`
|
||||||
|
默认 1/20、上限 100;传 `limit` 时保留旧版 `List<ProgressRespVO>` 响应。
|
||||||
|
- 观看进度使用 `selectPage`,按 `updated_at DESC, drama_id DESC` 排序。
|
||||||
|
- `ProgressRespVO` 增加 `dramaTitle`、`dramaCover`、`episodeNumber`、`totalEpisodes`;
|
||||||
|
当前页 dramaId / episodeId 去重排序后分别执行一次批量查询,无逐条 N+1。
|
||||||
|
- `docs/api-phase1.md §1.5` 已同步分页参数、默认值、上限、稳定排序、响应样例及
|
||||||
|
`limit` 兼容策略。
|
||||||
|
|
||||||
|
### 编译
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ mvn -pl fruitshorts-app-api -am -DskipTests compile
|
||||||
|
[INFO] fruitshorts-module-playback ......................... SUCCESS
|
||||||
|
[INFO] fruitshorts-app-api .................................. SUCCESS
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
[INFO] Total time: 9.888 s
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:`IPage/Page`、新 VO 字段和 Controller 返回类型均通过编译。
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
首次全量测试的两个失败均为测试直接展开 LambdaWrapper SQL 时缺少 MyBatis-Plus
|
||||||
|
实体缓存,不是生产逻辑失败:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MybatisPlusException: can not find lambda cache for this entity
|
||||||
|
[UserFavorite]
|
||||||
|
[WatchProgress]
|
||||||
|
```
|
||||||
|
|
||||||
|
按项目现有测试模式增加 `TableInfoHelper.initTableInfo(...)` 后,定向测试先通过:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ mvn -pl fruitshorts-app-api -am -Dtest=FavoriteServiceTest,WatchProgressServiceTest,AppLibraryPagingContractTest -Dsurefire.failIfNoSpecifiedTests=false test
|
||||||
|
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
```
|
||||||
|
|
||||||
|
最终 app-api 及改动依赖模块全量门禁:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ mvn -pl fruitshorts-app-api -am test
|
||||||
|
[INFO] Tests run: 109, Failures: 0, Errors: 0, Skipped: 0
|
||||||
|
[INFO] Reactor Summary for fruitshorts 1.0.0-SNAPSHOT:
|
||||||
|
[INFO] fruitshorts-module-content ......................... SUCCESS
|
||||||
|
[INFO] fruitshorts-module-playback ........................ SUCCESS
|
||||||
|
[INFO] fruitshorts-app-api ................................ SUCCESS
|
||||||
|
[INFO] BUILD SUCCESS
|
||||||
|
[INFO] Total time: 10.918 s
|
||||||
|
```
|
||||||
|
|
||||||
|
解读:收藏默认 pageSize=20、第二页不重叠、total 正确;观看历史分页、
|
||||||
|
dramaTitle/episodeNumber/totalEpisodes 非空、越界页空 list 且 total 保持、
|
||||||
|
`limit` 旧 List 兼容均通过;服务层断言 `selectPage`、稳定排序和两批 IN 查询。
|
||||||
|
未启停服务、未操作真机、未修改客户端。
|
||||||
|
|
||||||
+22
@@ -5,7 +5,10 @@ package com.fruitshorts.admin.controller;
|
|||||||
import com.fruitshorts.common.pojo.Result;
|
import com.fruitshorts.common.pojo.Result;
|
||||||
import com.fruitshorts.framework.security.SecurityUtils;
|
import com.fruitshorts.framework.security.SecurityUtils;
|
||||||
import com.fruitshorts.framework.security.ImpersonationGuard;
|
import com.fruitshorts.framework.security.ImpersonationGuard;
|
||||||
|
import com.fruitshorts.framework.log.OperationLog;
|
||||||
|
import com.fruitshorts.media.config.MediaTranscodeProfile;
|
||||||
import com.fruitshorts.media.service.MediaService;
|
import com.fruitshorts.media.service.MediaService;
|
||||||
|
import com.fruitshorts.media.service.MediaTranscodeProfileService;
|
||||||
import com.fruitshorts.media.vo.MediaAssetRespVO;
|
import com.fruitshorts.media.vo.MediaAssetRespVO;
|
||||||
import com.fruitshorts.media.vo.UploadCompleteReqVO;
|
import com.fruitshorts.media.vo.UploadCompleteReqVO;
|
||||||
import com.fruitshorts.media.vo.UploadInitReqVO;
|
import com.fruitshorts.media.vo.UploadInitReqVO;
|
||||||
@@ -19,6 +22,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -35,6 +39,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
public class MediaController {
|
public class MediaController {
|
||||||
|
|
||||||
private final MediaService mediaService;
|
private final MediaService mediaService;
|
||||||
|
private final MediaTranscodeProfileService profileService;
|
||||||
|
|
||||||
@Operation(summary = "初始化分片上传")
|
@Operation(summary = "初始化分片上传")
|
||||||
@PostMapping("/upload/init")
|
@PostMapping("/upload/init")
|
||||||
@@ -78,4 +83,21 @@ public class MediaController {
|
|||||||
public Result<MediaAssetRespVO> get(@PathVariable Long id) {
|
public Result<MediaAssetRespVO> get(@PathVariable Long id) {
|
||||||
return Result.ok(mediaService.get(id));
|
return Result.ok(mediaService.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "读取转码档位配置")
|
||||||
|
@GetMapping("/transcode-profile")
|
||||||
|
@PreAuthorize("@ss.hasPermission('episode:upload')")
|
||||||
|
public Result<MediaTranscodeProfile> transcodeProfile() {
|
||||||
|
return Result.ok(profileService.readConfiguredProfile());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "保存转码档位配置")
|
||||||
|
@PutMapping("/transcode-profile")
|
||||||
|
@PreAuthorize("@ss.hasPermission('episode:upload')")
|
||||||
|
@OperationLog(module = "后台管理", action = "修改转码设置")
|
||||||
|
public Result<MediaTranscodeProfile> saveTranscodeProfile(
|
||||||
|
@Valid @RequestBody MediaTranscodeProfile profile) {
|
||||||
|
ImpersonationGuard.rejectWrite();
|
||||||
|
return Result.ok(profileService.save(profile));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fruitshorts.admin.support.ControllerContractSupport;
|
||||||
|
import com.fruitshorts.media.config.MediaTranscodeProfile;
|
||||||
|
import com.fruitshorts.media.config.TranscodeProperties;
|
||||||
|
import com.fruitshorts.media.service.MediaService;
|
||||||
|
import com.fruitshorts.media.service.MediaTranscodeProfileService;
|
||||||
|
import com.fruitshorts.system.service.ConfigService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台转码配置接口:默认值、非法拒绝与成功落库。
|
||||||
|
*/
|
||||||
|
class MediaTranscodeProfileContractTest {
|
||||||
|
|
||||||
|
private ConfigService configService;
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
configService = mock(ConfigService.class);
|
||||||
|
objectMapper = new ObjectMapper();
|
||||||
|
MediaTranscodeProfileService profileService = new MediaTranscodeProfileService(
|
||||||
|
configService, objectMapper, new TranscodeProperties());
|
||||||
|
MediaController controller = new MediaController(mock(MediaService.class), profileService);
|
||||||
|
mockMvc = ControllerContractSupport.mockMvcOf(controller);
|
||||||
|
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
|
||||||
|
.willReturn("");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_return_default_profile_when_config_is_missing() throws Exception {
|
||||||
|
mockMvc.perform(get("/admin/media/transcode-profile"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.segmentSeconds").value(6))
|
||||||
|
.andExpect(jsonPath("$.data.ladder.length()").value(4))
|
||||||
|
.andExpect(jsonPath("$.data.ladder[0].quality").value("360p"))
|
||||||
|
.andExpect(jsonPath("$.data.sprite.columns").value(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_reject_illegal_profile_without_writing_config() throws Exception {
|
||||||
|
MediaTranscodeProfile profile = new MediaTranscodeProfile();
|
||||||
|
profile.getLadder().get(0).setWidth(641);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/admin/media/transcode-profile")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(profile)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(400))
|
||||||
|
.andExpect(jsonPath("$.message").value("宽高必须为正偶数"));
|
||||||
|
|
||||||
|
verify(configService, never()).update(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_persist_profile_when_request_is_valid() throws Exception {
|
||||||
|
MediaTranscodeProfile profile = new MediaTranscodeProfile();
|
||||||
|
profile.setSegmentSeconds(4);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/admin/media/transcode-profile")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(profile)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.segmentSeconds").value(4));
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<Map<String, String>> captor = ArgumentCaptor.forClass(Map.class);
|
||||||
|
verify(configService).update(captor.capture());
|
||||||
|
assertThat(captor.getValue().get(MediaTranscodeProfileService.CONFIG_KEY))
|
||||||
|
.contains("\"segmentSeconds\":4");
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-5
@@ -4,7 +4,9 @@ package com.fruitshorts.app.controller;
|
|||||||
|
|
||||||
import com.fruitshorts.app.service.AppCatalogService;
|
import com.fruitshorts.app.service.AppCatalogService;
|
||||||
import com.fruitshorts.app.vo.AppDramaCardVO;
|
import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
import com.fruitshorts.common.pojo.Result;
|
import com.fruitshorts.common.pojo.Result;
|
||||||
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
import com.fruitshorts.framework.security.SecurityUtils;
|
import com.fruitshorts.framework.security.SecurityUtils;
|
||||||
import com.fruitshorts.playback.service.FavoriteService;
|
import com.fruitshorts.playback.service.FavoriteService;
|
||||||
import com.fruitshorts.playback.vo.FavoriteToggleReqVO;
|
import com.fruitshorts.playback.vo.FavoriteToggleReqVO;
|
||||||
@@ -18,10 +20,9 @@ import org.springframework.web.bind.annotation.PathVariable;
|
|||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/** 收藏。 */
|
/** 收藏。 */
|
||||||
@Tag(name = "客户端-收藏")
|
@Tag(name = "客户端-收藏")
|
||||||
@RestController
|
@RestController
|
||||||
@@ -42,9 +43,13 @@ public class AppFavoriteController {
|
|||||||
/** 收藏列表,卡片结构与首页一致。 */
|
/** 收藏列表,卡片结构与首页一致。 */
|
||||||
@Operation(summary = "我的收藏列表")
|
@Operation(summary = "我的收藏列表")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public Result<List<AppDramaCardVO>> list() {
|
public Result<PageResult<AppDramaCardVO>> list(
|
||||||
return Result.ok(appCatalogService.toCards(
|
@RequestParam(defaultValue = "1") int pageIndex,
|
||||||
favoriteService.listDramas(SecurityUtils.getAppUserId())));
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
|
PageResult<Drama> page = favoriteService.page(
|
||||||
|
SecurityUtils.getAppUserId(), pageIndex, pageSize);
|
||||||
|
return Result.ok(new PageResult<>(
|
||||||
|
page.getTotal(), appCatalogService.toCards(page.getList())));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "取消收藏")
|
@Operation(summary = "取消收藏")
|
||||||
|
|||||||
+8
-5
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
package com.fruitshorts.app.controller;
|
package com.fruitshorts.app.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
import com.fruitshorts.common.pojo.Result;
|
import com.fruitshorts.common.pojo.Result;
|
||||||
import com.fruitshorts.framework.security.SecurityUtils;
|
import com.fruitshorts.framework.security.SecurityUtils;
|
||||||
import com.fruitshorts.playback.service.WatchProgressService;
|
import com.fruitshorts.playback.service.WatchProgressService;
|
||||||
@@ -20,8 +21,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/** 观看进度与观看记录。 */
|
/** 观看进度与观看记录。 */
|
||||||
@Tag(name = "客户端-观看进度")
|
@Tag(name = "客户端-观看进度")
|
||||||
@RestController
|
@RestController
|
||||||
@@ -45,10 +44,14 @@ public class AppWatchController {
|
|||||||
return Result.ok(progressService.ofDrama(SecurityUtils.getAppUserId(), dramaId));
|
return Result.ok(progressService.ofDrama(SecurityUtils.getAppUserId(), dramaId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "最近观看列表")
|
/** 最近观看分页,契约与 /app/favorites 一致:pageIndex/pageSize。 */
|
||||||
|
@Operation(summary = "最近观看分页")
|
||||||
@GetMapping("/recent")
|
@GetMapping("/recent")
|
||||||
public Result<List<ProgressRespVO>> recent(@RequestParam(defaultValue = "20") int limit) {
|
public Result<PageResult<ProgressRespVO>> recent(
|
||||||
return Result.ok(progressService.recent(SecurityUtils.getAppUserId(), limit));
|
@RequestParam(defaultValue = "1") int pageIndex,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize) {
|
||||||
|
return Result.ok(progressService.recentPage(
|
||||||
|
SecurityUtils.getAppUserId(), pageIndex, pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "删除某部剧的观看记录")
|
@Operation(summary = "删除某部剧的观看记录")
|
||||||
|
|||||||
+16
-5
@@ -20,6 +20,7 @@ import com.fruitshorts.content.service.DramaService;
|
|||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.interaction.service.CommentService;
|
import com.fruitshorts.interaction.service.CommentService;
|
||||||
import com.fruitshorts.media.service.PlaybackSigner;
|
import com.fruitshorts.media.service.PlaybackSigner;
|
||||||
|
import com.fruitshorts.media.service.ThumbnailUrlResolver;
|
||||||
import com.fruitshorts.content.vo.DramaCategoryRespVO;
|
import com.fruitshorts.content.vo.DramaCategoryRespVO;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -50,6 +51,7 @@ public class AppCatalogService {
|
|||||||
private final DramaCategoryService dramaCategoryService;
|
private final DramaCategoryService dramaCategoryService;
|
||||||
private final PlaybackSigner playbackSigner;
|
private final PlaybackSigner playbackSigner;
|
||||||
private final CommentService commentService;
|
private final CommentService commentService;
|
||||||
|
private final ThumbnailUrlResolver thumbnailUrlResolver;
|
||||||
|
|
||||||
public AppFeedVO homeFeed() {
|
public AppFeedVO homeFeed() {
|
||||||
Map<Long, String> categoryNames = categoryNameMap();
|
Map<Long, String> categoryNames = categoryNameMap();
|
||||||
@@ -110,6 +112,8 @@ public class AppCatalogService {
|
|||||||
List<DramaEpisode> episodes = episodeService.listPublishedByDrama(dramaId);
|
List<DramaEpisode> episodes = episodeService.listPublishedByDrama(dramaId);
|
||||||
Map<Long, Long> episodeCommentCounts = commentService.countByEpisodes(
|
Map<Long, Long> episodeCommentCounts = commentService.countByEpisodes(
|
||||||
episodes.stream().map(DramaEpisode::getId).sorted().toList());
|
episodes.stream().map(DramaEpisode::getId).sorted().toList());
|
||||||
|
Map<Long, String> thumbnailUrls = thumbnailUrlResolver.resolveAll(
|
||||||
|
episodes.stream().map(DramaEpisode::getId).sorted().toList());
|
||||||
String categoryName = categoryNameMap().getOrDefault(drama.getCategoryId(), "");
|
String categoryName = categoryNameMap().getOrDefault(drama.getCategoryId(), "");
|
||||||
|
|
||||||
AppDramaDetailVO detail = new AppDramaDetailVO();
|
AppDramaDetailVO detail = new AppDramaDetailVO();
|
||||||
@@ -128,7 +132,7 @@ public class AppCatalogService {
|
|||||||
detail.setFavoriteCount(drama.getFavoriteCount());
|
detail.setFavoriteCount(drama.getFavoriteCount());
|
||||||
detail.setCommentCount(commentService.countByDrama(dramaId));
|
detail.setCommentCount(commentService.countByDrama(dramaId));
|
||||||
detail.setEpisodes(episodes.stream()
|
detail.setEpisodes(episodes.stream()
|
||||||
.map(episode -> toEpisode(episode, episodeCommentCounts))
|
.map(episode -> toEpisode(episode, episodeCommentCounts, thumbnailUrls))
|
||||||
.toList());
|
.toList());
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
@@ -176,14 +180,18 @@ public class AppCatalogService {
|
|||||||
Map<Long, DramaEpisode> previews = episodeService.firstPlayableByDramas(ids);
|
Map<Long, DramaEpisode> previews = episodeService.firstPlayableByDramas(ids);
|
||||||
Map<Long, Long> commentCounts = commentService.countByEpisodes(
|
Map<Long, Long> commentCounts = commentService.countByEpisodes(
|
||||||
previews.values().stream().map(DramaEpisode::getId).sorted().toList());
|
previews.values().stream().map(DramaEpisode::getId).sorted().toList());
|
||||||
|
Map<Long, String> thumbnailUrls = thumbnailUrlResolver.resolveAll(
|
||||||
|
previews.values().stream().map(DramaEpisode::getId).sorted().toList());
|
||||||
return dramas.stream()
|
return dramas.stream()
|
||||||
.map(drama -> toCard(
|
.map(drama -> toCard(
|
||||||
drama, categoryNames, previews.get(drama.getId()), commentCounts))
|
drama, categoryNames, previews.get(drama.getId()), commentCounts,
|
||||||
|
thumbnailUrls))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private AppDramaCardVO toCard(Drama drama, Map<Long, String> categoryNames,
|
private AppDramaCardVO toCard(Drama drama, Map<Long, String> categoryNames,
|
||||||
DramaEpisode preview, Map<Long, Long> commentCounts) {
|
DramaEpisode preview, Map<Long, Long> commentCounts,
|
||||||
|
Map<Long, String> thumbnailUrls) {
|
||||||
AppDramaCardVO card = new AppDramaCardVO();
|
AppDramaCardVO card = new AppDramaCardVO();
|
||||||
card.setId(drama.getId());
|
card.setId(drama.getId());
|
||||||
card.setTitle(drama.getTitle());
|
card.setTitle(drama.getTitle());
|
||||||
@@ -201,11 +209,13 @@ public class AppCatalogService {
|
|||||||
? 0L : commentCounts.getOrDefault(preview.getId(), 0L));
|
? 0L : commentCounts.getOrDefault(preview.getId(), 0L));
|
||||||
card.setLanguage(drama.getLanguage());
|
card.setLanguage(drama.getLanguage());
|
||||||
card.setDramaType(drama.getDramaType());
|
card.setDramaType(drama.getDramaType());
|
||||||
card.setPreview(preview == null ? null : toEpisode(preview, commentCounts));
|
card.setPreview(preview == null
|
||||||
|
? null : toEpisode(preview, commentCounts, thumbnailUrls));
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
private AppEpisodeVO toEpisode(DramaEpisode episode, Map<Long, Long> commentCounts) {
|
private AppEpisodeVO toEpisode(DramaEpisode episode, Map<Long, Long> commentCounts,
|
||||||
|
Map<Long, String> thumbnailUrls) {
|
||||||
AppEpisodeVO vo = new AppEpisodeVO();
|
AppEpisodeVO vo = new AppEpisodeVO();
|
||||||
vo.setId(episode.getId());
|
vo.setId(episode.getId());
|
||||||
vo.setNumber(episode.getEpNo());
|
vo.setNumber(episode.getEpNo());
|
||||||
@@ -219,6 +229,7 @@ public class AppCatalogService {
|
|||||||
vo.setUnlockCoin(episode.getUnlockCoin());
|
vo.setUnlockCoin(episode.getUnlockCoin());
|
||||||
vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount());
|
vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount());
|
||||||
vo.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L));
|
vo.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L));
|
||||||
|
vo.setThumbnailsUrl(thumbnailUrls.get(episode.getId()));
|
||||||
if (free) {
|
if (free) {
|
||||||
// 播放地址统一在这里签名:客户端拿不到可长期复用的裸地址
|
// 播放地址统一在这里签名:客户端拿不到可长期复用的裸地址
|
||||||
vo.setPlayUrl(playbackSigner.sign(playableSource(episode)));
|
vo.setPlayUrl(playbackSigner.sign(playableSource(episode)));
|
||||||
|
|||||||
@@ -24,4 +24,6 @@ public class AppEpisodeVO {
|
|||||||
* 详情页拿到就能直接播,不必按集再请求一次播放票据。
|
* 详情页拿到就能直接播,不必按集再请求一次播放票据。
|
||||||
*/
|
*/
|
||||||
private String playUrl;
|
private String playUrl;
|
||||||
|
/** 雪碧图 WebVTT 时间轴地址;没有产物时为 null */
|
||||||
|
private String thumbnailsUrl;
|
||||||
}
|
}
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.app.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.app.service.AppCatalogService;
|
||||||
|
import com.fruitshorts.app.support.ControllerContractSupport;
|
||||||
|
import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
|
import com.fruitshorts.framework.security.LoginUser;
|
||||||
|
import com.fruitshorts.playback.service.FavoriteService;
|
||||||
|
import com.fruitshorts.playback.service.WatchProgressService;
|
||||||
|
import com.fruitshorts.playback.vo.ProgressRespVO;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/** 客户端收藏与观看历史分页契约。 */
|
||||||
|
class AppLibraryPagingContractTest {
|
||||||
|
|
||||||
|
private static final long USER_ID = 1001L;
|
||||||
|
|
||||||
|
private FavoriteService favoriteService;
|
||||||
|
private AppCatalogService catalogService;
|
||||||
|
private WatchProgressService progressService;
|
||||||
|
private MockMvc favoriteMvc;
|
||||||
|
private MockMvc watchMvc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
favoriteService = mock(FavoriteService.class);
|
||||||
|
catalogService = mock(AppCatalogService.class);
|
||||||
|
progressService = mock(WatchProgressService.class);
|
||||||
|
favoriteMvc = ControllerContractSupport.mockMvcOf(
|
||||||
|
new AppFavoriteController(favoriteService, catalogService));
|
||||||
|
watchMvc = ControllerContractSupport.mockMvcOf(
|
||||||
|
new AppWatchController(progressService));
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new UsernamePasswordAuthenticationToken(
|
||||||
|
new LoginUser(USER_ID, "app-user", "APP_USER", List.of()),
|
||||||
|
null,
|
||||||
|
List.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_page_favorites_with_default_size_and_non_overlapping_second_page()
|
||||||
|
throws Exception {
|
||||||
|
Drama first = drama(101L, "收藏一");
|
||||||
|
Drama second = drama(102L, "收藏二");
|
||||||
|
Drama third = drama(103L, "收藏三");
|
||||||
|
given(favoriteService.page(USER_ID, 1, 20))
|
||||||
|
.willReturn(new PageResult<>(21L, List.of(first, second)));
|
||||||
|
given(favoriteService.page(USER_ID, 2, 20))
|
||||||
|
.willReturn(new PageResult<>(21L, List.of(third)));
|
||||||
|
given(catalogService.toCards(List.of(first, second)))
|
||||||
|
.willReturn(List.of(card(101L), card(102L)));
|
||||||
|
given(catalogService.toCards(List.of(third)))
|
||||||
|
.willReturn(List.of(card(103L)));
|
||||||
|
|
||||||
|
favoriteMvc.perform(get("/app/favorites"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.total").value("21"))
|
||||||
|
.andExpect(jsonPath("$.data.list.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value("101"))
|
||||||
|
.andExpect(jsonPath("$.data.list[1].id").value("102"));
|
||||||
|
|
||||||
|
favoriteMvc.perform(get("/app/favorites").param("pageIndex", "2"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.total").value("21"))
|
||||||
|
.andExpect(jsonPath("$.data.list.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].id").value("103"));
|
||||||
|
|
||||||
|
verify(favoriteService).page(USER_ID, 1, 20);
|
||||||
|
verify(favoriteService).page(USER_ID, 2, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_page_recent_watch_with_display_fields() throws Exception {
|
||||||
|
ProgressRespVO first = progress(12L, 88L, 6, "剧名一", 68);
|
||||||
|
ProgressRespVO second = progress(13L, 99L, 2, "剧名二", 20);
|
||||||
|
ProgressRespVO third = progress(14L, 100L, 1, "剧名三", 16);
|
||||||
|
given(progressService.recentPage(USER_ID, 1, 20))
|
||||||
|
.willReturn(new PageResult<>(23L, List.of(first, second)));
|
||||||
|
given(progressService.recentPage(USER_ID, 2, 20))
|
||||||
|
.willReturn(new PageResult<>(23L, List.of(third)));
|
||||||
|
|
||||||
|
watchMvc.perform(get("/app/watch/recent"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.total").value("23"))
|
||||||
|
.andExpect(jsonPath("$.data.list.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].dramaTitle").value("剧名一"))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].episodeNumber").value(6))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].totalEpisodes").value(68))
|
||||||
|
.andExpect(jsonPath("$.data.list[1].dramaTitle").value("剧名二"))
|
||||||
|
.andExpect(jsonPath("$.data.list[1].episodeNumber").value(2));
|
||||||
|
|
||||||
|
watchMvc.perform(get("/app/watch/recent").param("pageIndex", "2"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.total").value("23"))
|
||||||
|
.andExpect(jsonPath("$.data.list.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].dramaId").value("14"))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].dramaTitle").value("剧名三"));
|
||||||
|
|
||||||
|
verify(progressService).recentPage(USER_ID, 1, 20);
|
||||||
|
verify(progressService).recentPage(USER_ID, 2, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_keep_total_and_return_empty_list_when_watch_page_is_out_of_range()
|
||||||
|
throws Exception {
|
||||||
|
given(progressService.recentPage(USER_ID, 9, 20))
|
||||||
|
.willReturn(new PageResult<>(23L, List.of()));
|
||||||
|
|
||||||
|
watchMvc.perform(get("/app/watch/recent").param("pageIndex", "9"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.total").value("23"))
|
||||||
|
.andExpect(jsonPath("$.data.list").isArray())
|
||||||
|
.andExpect(jsonPath("$.data.list").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_ignore_removed_limit_and_still_return_page_result() throws Exception {
|
||||||
|
// limit 已从契约移除:即使客户端误传,也应走分页并返回 PageResult。
|
||||||
|
given(progressService.recentPage(USER_ID, 1, 20))
|
||||||
|
.willReturn(new PageResult<>(3L, List.of(progress(12L, 88L, 6, "剧名一", 68))));
|
||||||
|
|
||||||
|
watchMvc.perform(get("/app/watch/recent").param("limit", "5"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.total").value("3"))
|
||||||
|
.andExpect(jsonPath("$.data.list").isArray())
|
||||||
|
.andExpect(jsonPath("$.data.list.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.list[0].dramaTitle").value("剧名一"));
|
||||||
|
|
||||||
|
verify(progressService).recentPage(USER_ID, 1, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Drama drama(Long id, String title) {
|
||||||
|
Drama drama = new Drama();
|
||||||
|
drama.setId(id);
|
||||||
|
drama.setTitle(title);
|
||||||
|
return drama;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppDramaCardVO card(Long id) {
|
||||||
|
AppDramaCardVO card = new AppDramaCardVO();
|
||||||
|
card.setId(id);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProgressRespVO progress(Long dramaId, Long episodeId, int episodeNumber,
|
||||||
|
String dramaTitle, int totalEpisodes) {
|
||||||
|
ProgressRespVO vo = new ProgressRespVO();
|
||||||
|
vo.setDramaId(dramaId);
|
||||||
|
vo.setEpisodeId(episodeId);
|
||||||
|
vo.setEpisodeNumber(episodeNumber);
|
||||||
|
vo.setDramaTitle(dramaTitle);
|
||||||
|
vo.setDramaCover("/files/cover/" + dramaId + ".jpg");
|
||||||
|
vo.setTotalEpisodes(totalEpisodes);
|
||||||
|
vo.setPositionMs(80_000L);
|
||||||
|
vo.setDurationMs(120_000L);
|
||||||
|
vo.setFinished(false);
|
||||||
|
vo.setPlayCount(1);
|
||||||
|
vo.setUpdatedAt(LocalDateTime.of(2026, 10, 22, 10, 0));
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-4
@@ -15,6 +15,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.nullValue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.ArgumentMatchers.isNull;
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
@@ -55,7 +56,9 @@ class AppPlaybackContractTest {
|
|||||||
@Test
|
@Test
|
||||||
void should_return_free_access_when_episode_is_free() throws Exception {
|
void should_return_free_access_when_episode_is_free() throws Exception {
|
||||||
given(playbackService.play(isNull(), eq(11L), any(), any()))
|
given(playbackService.play(isNull(), eq(11L), any(), any()))
|
||||||
.willReturn(ticket(11L, true, PlaybackService.ACCESS_FREE, "/files/ep.mp4?s=1"));
|
.willReturn(ticket(11L, true, PlaybackService.ACCESS_FREE,
|
||||||
|
"/files/ep.mp4?s=1",
|
||||||
|
"/files/transcoded/11/thumbnails/thumbnails.vtt?e=1&s=sig"));
|
||||||
|
|
||||||
mockMvc.perform(get("/app/episodes/11/play").header("X-Device-Id", "device-9"))
|
mockMvc.perform(get("/app/episodes/11/play").header("X-Device-Id", "device-9"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -66,13 +69,15 @@ class AppPlaybackContractTest {
|
|||||||
.andExpect(jsonPath("$.data.accessible").value(true))
|
.andExpect(jsonPath("$.data.accessible").value(true))
|
||||||
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_FREE))
|
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_FREE))
|
||||||
.andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1"))
|
.andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1"))
|
||||||
|
.andExpect(jsonPath("$.data.thumbnailsUrl")
|
||||||
|
.value("/files/transcoded/11/thumbnails/thumbnails.vtt?e=1&s=sig"))
|
||||||
.andExpect(jsonPath("$.data.likeCount").value("88"))
|
.andExpect(jsonPath("$.data.likeCount").value("88"))
|
||||||
.andExpect(jsonPath("$.data.commentCount").value("0"));
|
.andExpect(jsonPath("$.data.commentCount").value("0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_return_coin_required_when_episode_is_locked_and_not_entitled() throws Exception {
|
void should_return_coin_required_when_episode_is_locked_and_not_entitled() throws Exception {
|
||||||
PlayRespVO locked = ticket(12L, false, PlaybackService.ACCESS_COIN_REQUIRED, "");
|
PlayRespVO locked = ticket(12L, false, PlaybackService.ACCESS_COIN_REQUIRED, "", null);
|
||||||
locked.setUnlockCoin(60);
|
locked.setUnlockCoin(60);
|
||||||
given(playbackService.play(isNull(), eq(12L), any(), any())).willReturn(locked);
|
given(playbackService.play(isNull(), eq(12L), any(), any())).willReturn(locked);
|
||||||
|
|
||||||
@@ -81,13 +86,15 @@ class AppPlaybackContractTest {
|
|||||||
.andExpect(jsonPath("$.data.accessible").value(false))
|
.andExpect(jsonPath("$.data.accessible").value(false))
|
||||||
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_COIN_REQUIRED))
|
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_COIN_REQUIRED))
|
||||||
.andExpect(jsonPath("$.data.playUrl").value(""))
|
.andExpect(jsonPath("$.data.playUrl").value(""))
|
||||||
|
.andExpect(jsonPath("$.data.thumbnailsUrl").value(nullValue()))
|
||||||
.andExpect(jsonPath("$.data.unlockCoin").value(60));
|
.andExpect(jsonPath("$.data.unlockCoin").value(60));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_pass_episode_id_and_device_header_when_play_is_requested() throws Exception {
|
void should_pass_episode_id_and_device_header_when_play_is_requested() throws Exception {
|
||||||
given(playbackService.play(isNull(), eq(13L), eq("device-9"), any()))
|
given(playbackService.play(isNull(), eq(13L), eq("device-9"), any()))
|
||||||
.willReturn(ticket(13L, true, PlaybackService.ACCESS_FREE, "/files/ep13.mp4?s=1"));
|
.willReturn(ticket(13L, true, PlaybackService.ACCESS_FREE,
|
||||||
|
"/files/ep13.mp4?s=1", null));
|
||||||
|
|
||||||
mockMvc.perform(get("/app/episodes/13/play").header("X-Device-Id", "device-9"))
|
mockMvc.perform(get("/app/episodes/13/play").header("X-Device-Id", "device-9"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -95,7 +102,8 @@ class AppPlaybackContractTest {
|
|||||||
verify(playbackService).play(isNull(), eq(13L), eq("device-9"), any());
|
verify(playbackService).play(isNull(), eq(13L), eq("device-9"), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
private PlayRespVO ticket(Long episodeId, boolean accessible, String accessType, String playUrl) {
|
private PlayRespVO ticket(Long episodeId, boolean accessible, String accessType,
|
||||||
|
String playUrl, String thumbnailsUrl) {
|
||||||
PlayRespVO vo = new PlayRespVO();
|
PlayRespVO vo = new PlayRespVO();
|
||||||
vo.setEpisodeId(episodeId);
|
vo.setEpisodeId(episodeId);
|
||||||
vo.setDramaId(7L);
|
vo.setDramaId(7L);
|
||||||
@@ -105,6 +113,7 @@ class AppPlaybackContractTest {
|
|||||||
vo.setAccessible(accessible);
|
vo.setAccessible(accessible);
|
||||||
vo.setAccessType(accessType);
|
vo.setAccessType(accessType);
|
||||||
vo.setPlayUrl(playUrl);
|
vo.setPlayUrl(playUrl);
|
||||||
|
vo.setThumbnailsUrl(thumbnailsUrl);
|
||||||
vo.setQualities(List.of("AUTO"));
|
vo.setQualities(List.of("AUTO"));
|
||||||
vo.setUnlockCoin(0);
|
vo.setUnlockCoin(0);
|
||||||
vo.setPreviewSeconds(0);
|
vo.setPreviewSeconds(0);
|
||||||
|
|||||||
+10
-1
@@ -12,6 +12,7 @@ import com.fruitshorts.content.service.DramaService;
|
|||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.interaction.service.CommentService;
|
import com.fruitshorts.interaction.service.CommentService;
|
||||||
import com.fruitshorts.media.service.PlaybackSigner;
|
import com.fruitshorts.media.service.PlaybackSigner;
|
||||||
|
import com.fruitshorts.media.service.ThumbnailUrlResolver;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ class AppCatalogServiceTest {
|
|||||||
private EpisodeService episodeService;
|
private EpisodeService episodeService;
|
||||||
private DramaCategoryService dramaCategoryService;
|
private DramaCategoryService dramaCategoryService;
|
||||||
private CommentService commentService;
|
private CommentService commentService;
|
||||||
|
private ThumbnailUrlResolver thumbnailUrlResolver;
|
||||||
private AppCatalogService catalogService;
|
private AppCatalogService catalogService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -44,9 +46,11 @@ class AppCatalogServiceTest {
|
|||||||
BannerService bannerService = mock(BannerService.class);
|
BannerService bannerService = mock(BannerService.class);
|
||||||
dramaCategoryService = mock(DramaCategoryService.class);
|
dramaCategoryService = mock(DramaCategoryService.class);
|
||||||
PlaybackSigner playbackSigner = mock(PlaybackSigner.class);
|
PlaybackSigner playbackSigner = mock(PlaybackSigner.class);
|
||||||
|
thumbnailUrlResolver = mock(ThumbnailUrlResolver.class);
|
||||||
|
given(thumbnailUrlResolver.resolveAll(any())).willReturn(Map.of());
|
||||||
commentService = mock(CommentService.class);
|
commentService = mock(CommentService.class);
|
||||||
catalogService = new AppCatalogService(dramaService, episodeService, bannerService,
|
catalogService = new AppCatalogService(dramaService, episodeService, bannerService,
|
||||||
dramaCategoryService, playbackSigner, commentService);
|
dramaCategoryService, playbackSigner, commentService, thumbnailUrlResolver);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -66,6 +70,8 @@ class AppCatalogServiceTest {
|
|||||||
given(commentService.countByDrama(42L)).willReturn(96L);
|
given(commentService.countByDrama(42L)).willReturn(96L);
|
||||||
given(commentService.countByEpisodes(List.of(11L, 22L)))
|
given(commentService.countByEpisodes(List.of(11L, 22L)))
|
||||||
.willReturn(Map.of(11L, 2L, 22L, 4L));
|
.willReturn(Map.of(11L, 2L, 22L, 4L));
|
||||||
|
given(thumbnailUrlResolver.resolveAll(List.of(11L, 22L)))
|
||||||
|
.willReturn(Map.of(22L, "/files/transcoded/22/thumbnails/thumbnails.vtt"));
|
||||||
|
|
||||||
AppDramaDetailVO detail = catalogService.dramaDetail(42L);
|
AppDramaDetailVO detail = catalogService.dramaDetail(42L);
|
||||||
|
|
||||||
@@ -75,7 +81,10 @@ class AppCatalogServiceTest {
|
|||||||
assertThat(detail.getCommentCount()).isEqualTo(96L);
|
assertThat(detail.getCommentCount()).isEqualTo(96L);
|
||||||
assertThat(detail.getEpisodes().get(0).getLikeCount()).isEqualTo(66L);
|
assertThat(detail.getEpisodes().get(0).getLikeCount()).isEqualTo(66L);
|
||||||
assertThat(detail.getEpisodes().get(0).getCommentCount()).isEqualTo(4L);
|
assertThat(detail.getEpisodes().get(0).getCommentCount()).isEqualTo(4L);
|
||||||
|
assertThat(detail.getEpisodes().get(0).getThumbnailsUrl())
|
||||||
|
.isEqualTo("/files/transcoded/22/thumbnails/thumbnails.vtt");
|
||||||
verify(commentService).countByDrama(42L);
|
verify(commentService).countByDrama(42L);
|
||||||
|
verify(thumbnailUrlResolver).resolveAll(List.of(11L, 22L));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+3
-1
@@ -12,6 +12,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
|
|||||||
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
|
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
|
||||||
import com.fruitshorts.content.service.DramaCategoryService;
|
import com.fruitshorts.content.service.DramaCategoryService;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
|
import com.fruitshorts.content.service.EpisodePriceSync;
|
||||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -41,7 +42,8 @@ class DramaServiceFavoriteCountTest {
|
|||||||
dramaMapper,
|
dramaMapper,
|
||||||
mock(DramaEpisodeMapper.class),
|
mock(DramaEpisodeMapper.class),
|
||||||
mock(DramaReviewRecordMapper.class),
|
mock(DramaReviewRecordMapper.class),
|
||||||
mock(DramaCategoryService.class));
|
mock(DramaCategoryService.class),
|
||||||
|
mock(EpisodePriceSync.class));
|
||||||
given(dramaMapper.update(isNull(), any())).willReturn(1);
|
given(dramaMapper.update(isNull(), any())).willReturn(1);
|
||||||
ArgumentCaptor<Wrapper<Drama>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
ArgumentCaptor<Wrapper<Drama>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -11,6 +11,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
|
|||||||
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
|
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
|
||||||
import com.fruitshorts.content.service.DramaCategoryService;
|
import com.fruitshorts.content.service.DramaCategoryService;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
|
import com.fruitshorts.content.service.EpisodePriceSync;
|
||||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -45,7 +46,8 @@ class DramaServiceFeedTest {
|
|||||||
dramaMapper,
|
dramaMapper,
|
||||||
mock(DramaEpisodeMapper.class),
|
mock(DramaEpisodeMapper.class),
|
||||||
mock(DramaReviewRecordMapper.class),
|
mock(DramaReviewRecordMapper.class),
|
||||||
mock(DramaCategoryService.class));
|
mock(DramaCategoryService.class),
|
||||||
|
mock(EpisodePriceSync.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+3
-1
@@ -12,6 +12,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
|
|||||||
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
|
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
|
||||||
import com.fruitshorts.content.service.DramaCategoryService;
|
import com.fruitshorts.content.service.DramaCategoryService;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
|
import com.fruitshorts.content.service.EpisodePriceSync;
|
||||||
import com.fruitshorts.content.vo.DramaSaveReqVO;
|
import com.fruitshorts.content.vo.DramaSaveReqVO;
|
||||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
@@ -46,7 +47,8 @@ class DramaServiceK88Test {
|
|||||||
dramaMapper,
|
dramaMapper,
|
||||||
mock(DramaEpisodeMapper.class),
|
mock(DramaEpisodeMapper.class),
|
||||||
mock(DramaReviewRecordMapper.class),
|
mock(DramaReviewRecordMapper.class),
|
||||||
mock(DramaCategoryService.class));
|
mock(DramaCategoryService.class),
|
||||||
|
mock(EpisodePriceSync.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+3
-1
@@ -13,6 +13,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
|
|||||||
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
|
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
|
||||||
import com.fruitshorts.content.service.DramaCategoryService;
|
import com.fruitshorts.content.service.DramaCategoryService;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
|
import com.fruitshorts.content.service.EpisodePriceSync;
|
||||||
import com.fruitshorts.content.vo.DramaPageReqVO;
|
import com.fruitshorts.content.vo.DramaPageReqVO;
|
||||||
import com.fruitshorts.content.vo.DramaRespVO;
|
import com.fruitshorts.content.vo.DramaRespVO;
|
||||||
import com.fruitshorts.content.vo.DramaSaveReqVO;
|
import com.fruitshorts.content.vo.DramaSaveReqVO;
|
||||||
@@ -54,7 +55,8 @@ class DramaServiceMultiTagTest {
|
|||||||
dramaMapper,
|
dramaMapper,
|
||||||
mock(DramaEpisodeMapper.class),
|
mock(DramaEpisodeMapper.class),
|
||||||
mock(DramaReviewRecordMapper.class),
|
mock(DramaReviewRecordMapper.class),
|
||||||
categoryService);
|
categoryService,
|
||||||
|
mock(EpisodePriceSync.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+4
-1
@@ -13,6 +13,7 @@ import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
|||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
|
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
|
||||||
|
import com.fruitshorts.media.service.EpisodeTranscodeBindingService;
|
||||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -46,7 +47,9 @@ class EpisodeServiceK89Test {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
episodeMapper = mock(DramaEpisodeMapper.class);
|
episodeMapper = mock(DramaEpisodeMapper.class);
|
||||||
episodeService = new EpisodeService(episodeMapper, mock(DramaService.class));
|
episodeService = new EpisodeService(
|
||||||
|
episodeMapper, mock(DramaService.class),
|
||||||
|
mock(EpisodeTranscodeBindingService.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+26
-1
@@ -6,6 +6,7 @@ import com.fruitshorts.content.dal.entity.DramaEpisode;
|
|||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.media.service.PlaybackSigner;
|
import com.fruitshorts.media.service.PlaybackSigner;
|
||||||
|
import com.fruitshorts.media.service.ThumbnailUrlResolver;
|
||||||
import com.fruitshorts.media.service.TranscodeService;
|
import com.fruitshorts.media.service.TranscodeService;
|
||||||
import com.fruitshorts.playback.dal.entity.PlaybackTicket;
|
import com.fruitshorts.playback.dal.entity.PlaybackTicket;
|
||||||
import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper;
|
import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper;
|
||||||
@@ -42,6 +43,7 @@ class PlaybackServiceTest {
|
|||||||
private PlaybackSigner signer;
|
private PlaybackSigner signer;
|
||||||
private EntitlementPort entitlementPort;
|
private EntitlementPort entitlementPort;
|
||||||
private TranscodeService transcodeService;
|
private TranscodeService transcodeService;
|
||||||
|
private ThumbnailUrlResolver thumbnailUrlResolver;
|
||||||
private PlaybackService playbackService;
|
private PlaybackService playbackService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -52,10 +54,12 @@ class PlaybackServiceTest {
|
|||||||
signer = mock(PlaybackSigner.class);
|
signer = mock(PlaybackSigner.class);
|
||||||
entitlementPort = mock(EntitlementPort.class);
|
entitlementPort = mock(EntitlementPort.class);
|
||||||
transcodeService = mock(TranscodeService.class);
|
transcodeService = mock(TranscodeService.class);
|
||||||
|
thumbnailUrlResolver = mock(ThumbnailUrlResolver.class);
|
||||||
playbackService = new PlaybackService(episodeService, dramaService, ticketMapper, signer,
|
playbackService = new PlaybackService(episodeService, dramaService, ticketMapper, signer,
|
||||||
entitlementPort, transcodeService);
|
entitlementPort, transcodeService, thumbnailUrlResolver);
|
||||||
given(dramaService.getPublished(anyLong())).willReturn(drama());
|
given(dramaService.getPublished(anyLong())).willReturn(drama());
|
||||||
given(transcodeService.listRenditions(anyLong())).willReturn(List.of());
|
given(transcodeService.listRenditions(anyLong())).willReturn(List.of());
|
||||||
|
given(thumbnailUrlResolver.resolve(anyLong())).willReturn(null);
|
||||||
given(signer.sign(anyString())).willReturn("/files/episode/1.mp4?e=1&s=sig");
|
given(signer.sign(anyString())).willReturn("/files/episode/1.mp4?e=1&s=sig");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +76,27 @@ class PlaybackServiceTest {
|
|||||||
verify(ticketMapper, never()).insert(any(PlaybackTicket.class));
|
verify(ticketMapper, never()).insert(any(PlaybackTicket.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_return_signed_thumbnails_url_when_thumbnail_exists() {
|
||||||
|
given(episodeService.getPublished(11L)).willReturn(episode(11L, 1, 0, "/files/episode/1.mp4"));
|
||||||
|
given(thumbnailUrlResolver.resolve(11L))
|
||||||
|
.willReturn("/files/transcoded/1/thumbnails/thumbnails.vtt?e=1&s=sig");
|
||||||
|
|
||||||
|
PlayRespVO resp = playbackService.play(null, 11L, "device-9", "203.0.113.8");
|
||||||
|
|
||||||
|
assertThat(resp.getThumbnailsUrl())
|
||||||
|
.isEqualTo("/files/transcoded/1/thumbnails/thumbnails.vtt?e=1&s=sig");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_return_null_thumbnails_url_when_thumbnail_is_absent() {
|
||||||
|
given(episodeService.getPublished(11L)).willReturn(episode(11L, 1, 0, "/files/episode/1.mp4"));
|
||||||
|
|
||||||
|
PlayRespVO resp = playbackService.play(null, 11L, "device-9", "203.0.113.8");
|
||||||
|
|
||||||
|
assertThat(resp.getThumbnailsUrl()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_return_coin_required_when_episode_is_locked_and_not_entitled() {
|
void should_return_coin_required_when_episode_is_locked_and_not_entitled() {
|
||||||
given(episodeService.getPublished(12L)).willReturn(episode(12L, 0, 60, "/files/episode/2.mp4"));
|
given(episodeService.getPublished(12L)).willReturn(episode(12L, 0, 60, "/files/episode/2.mp4"));
|
||||||
|
|||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.app.service;
|
||||||
|
|
||||||
|
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||||
|
import com.fruitshorts.media.service.MediaStorageService;
|
||||||
|
import com.fruitshorts.media.service.PlaybackSigner;
|
||||||
|
import com.fruitshorts.media.service.ThumbnailUrlResolver;
|
||||||
|
import com.fruitshorts.media.service.TranscodeService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩略图 objectKey 到公开签名地址的转换。
|
||||||
|
*/
|
||||||
|
class ThumbnailUrlResolverTest {
|
||||||
|
|
||||||
|
private TranscodeService transcodeService;
|
||||||
|
private MediaStorageService storageService;
|
||||||
|
private PlaybackSigner signer;
|
||||||
|
private ThumbnailUrlResolver resolver;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
transcodeService = mock(TranscodeService.class);
|
||||||
|
storageService = mock(MediaStorageService.class);
|
||||||
|
signer = mock(PlaybackSigner.class);
|
||||||
|
resolver = new ThumbnailUrlResolver(transcodeService, storageService, signer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_sign_vtt_public_url_when_ready_thumbnail_exists() {
|
||||||
|
MediaThumbnail thumbnail = new MediaThumbnail();
|
||||||
|
thumbnail.setVttObjectKey("transcoded/88/thumbnails/thumbnails.vtt");
|
||||||
|
given(transcodeService.findReadyThumbnail(11L)).willReturn(thumbnail);
|
||||||
|
given(storageService.toPublicUrl(thumbnail.getVttObjectKey()))
|
||||||
|
.willReturn("/files/transcoded/88/thumbnails/thumbnails.vtt");
|
||||||
|
given(signer.sign("/files/transcoded/88/thumbnails/thumbnails.vtt"))
|
||||||
|
.willReturn("/files/transcoded/88/thumbnails/thumbnails.vtt?e=1&s=sig");
|
||||||
|
|
||||||
|
assertThat(resolver.resolve(11L))
|
||||||
|
.isEqualTo("/files/transcoded/88/thumbnails/thumbnails.vtt?e=1&s=sig");
|
||||||
|
verify(signer).sign("/files/transcoded/88/thumbnails/thumbnails.vtt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_return_null_when_thumbnail_record_is_absent() {
|
||||||
|
assertThat(resolver.resolve(12L)).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -2,13 +2,23 @@
|
|||||||
|
|
||||||
package com.fruitshorts.playback.service;
|
package com.fruitshorts.playback.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
import com.fruitshorts.playback.dal.entity.UserFavorite;
|
import com.fruitshorts.playback.dal.entity.UserFavorite;
|
||||||
import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper;
|
import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
@@ -20,6 +30,12 @@ import static org.mockito.Mockito.verify;
|
|||||||
/** 收藏关系变化时同步维护短剧收藏数。 */
|
/** 收藏关系变化时同步维护短剧收藏数。 */
|
||||||
class FavoriteServiceTest {
|
class FavoriteServiceTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initTableInfoCache() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, UserFavorite.class);
|
||||||
|
}
|
||||||
|
|
||||||
private UserFavoriteMapper favoriteMapper;
|
private UserFavoriteMapper favoriteMapper;
|
||||||
private DramaService dramaService;
|
private DramaService dramaService;
|
||||||
private FavoriteService favoriteService;
|
private FavoriteService favoriteService;
|
||||||
@@ -77,4 +93,46 @@ class FavoriteServiceTest {
|
|||||||
verify(dramaService, never()).adjustFavoriteCount(8L, -1);
|
verify(dramaService, never()).adjustFavoriteCount(8L, -1);
|
||||||
verify(dramaService).adjustFavoriteCount(9L, -1);
|
verify(dramaService).adjustFavoriteCount(9L, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_use_database_page_and_keep_favorite_order_when_listing() {
|
||||||
|
UserFavorite first = favorite(51L);
|
||||||
|
UserFavorite second = favorite(52L);
|
||||||
|
Page<UserFavorite> databasePage = new Page<>(2, 20, 25);
|
||||||
|
databasePage.setRecords(List.of(first, second));
|
||||||
|
given(favoriteMapper.selectPage(any(Page.class), any())).willReturn(databasePage);
|
||||||
|
Drama firstDrama = drama(51L);
|
||||||
|
Drama secondDrama = drama(52L);
|
||||||
|
given(dramaService.listPublishedByIds(List.of(51L, 52L)))
|
||||||
|
.willReturn(List.of(secondDrama, firstDrama));
|
||||||
|
|
||||||
|
PageResult<Drama> result = favoriteService.page(1L, 2, 20);
|
||||||
|
|
||||||
|
assertThat(result.getTotal()).isEqualTo(25);
|
||||||
|
assertThat(result.getList()).containsExactly(firstDrama, secondDrama);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<Page<UserFavorite>> pageCaptor = ArgumentCaptor.forClass(Page.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<Wrapper<UserFavorite>> wrapperCaptor =
|
||||||
|
ArgumentCaptor.forClass(Wrapper.class);
|
||||||
|
verify(favoriteMapper).selectPage(pageCaptor.capture(), wrapperCaptor.capture());
|
||||||
|
assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(2);
|
||||||
|
assertThat(pageCaptor.getValue().getSize()).isEqualTo(20);
|
||||||
|
assertThat(wrapperCaptor.getValue().getSqlSegment())
|
||||||
|
.contains("created_at DESC")
|
||||||
|
.contains("drama_id DESC");
|
||||||
|
verify(dramaService).listPublishedByIds(List.of(51L, 52L));
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserFavorite favorite(Long dramaId) {
|
||||||
|
UserFavorite favorite = new UserFavorite();
|
||||||
|
favorite.setDramaId(dramaId);
|
||||||
|
return favorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Drama drama(Long id) {
|
||||||
|
Drama drama = new Drama();
|
||||||
|
drama.setId(id);
|
||||||
|
return drama;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.playback.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
|
import com.fruitshorts.content.service.DramaService;
|
||||||
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
|
import com.fruitshorts.playback.dal.entity.WatchProgress;
|
||||||
|
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
|
||||||
|
import com.fruitshorts.playback.dal.mapper.WatchProgressMapper;
|
||||||
|
import com.fruitshorts.playback.vo.ProgressRespVO;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
/** 观看历史分页、稳定排序与批量展示字段装配。 */
|
||||||
|
class WatchProgressServiceTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initTableInfoCache() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, WatchProgress.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WatchProgressMapper progressMapper;
|
||||||
|
private DramaService dramaService;
|
||||||
|
private EpisodeService episodeService;
|
||||||
|
private WatchProgressService progressService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
progressMapper = mock(WatchProgressMapper.class);
|
||||||
|
dramaService = mock(DramaService.class);
|
||||||
|
episodeService = mock(EpisodeService.class);
|
||||||
|
progressService = new WatchProgressService(
|
||||||
|
progressMapper,
|
||||||
|
mock(WatchHistoryMapper.class),
|
||||||
|
dramaService,
|
||||||
|
episodeService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_page_recent_and_fill_display_fields_with_two_batch_queries() {
|
||||||
|
WatchProgress first = progress(12L, 88L);
|
||||||
|
WatchProgress second = progress(13L, 99L);
|
||||||
|
Page<WatchProgress> databasePage = new Page<>(1, 20, 23);
|
||||||
|
databasePage.setRecords(List.of(first, second));
|
||||||
|
given(progressMapper.selectPage(any(Page.class), any())).willReturn(databasePage);
|
||||||
|
given(dramaService.listByIds(List.of(12L, 13L)))
|
||||||
|
.willReturn(List.of(drama(13L, "剧名二", 20), drama(12L, "剧名一", 68)));
|
||||||
|
given(episodeService.listByIds(List.of(88L, 99L)))
|
||||||
|
.willReturn(List.of(episode(99L, 2), episode(88L, 6)));
|
||||||
|
|
||||||
|
PageResult<ProgressRespVO> result =
|
||||||
|
progressService.recentPage(1001L, 1, 20);
|
||||||
|
|
||||||
|
assertThat(result.getTotal()).isEqualTo(23);
|
||||||
|
assertThat(result.getList()).hasSize(2);
|
||||||
|
assertThat(result.getList().get(0).getDramaTitle()).isEqualTo("剧名一");
|
||||||
|
assertThat(result.getList().get(0).getEpisodeNumber()).isEqualTo(6);
|
||||||
|
assertThat(result.getList().get(0).getTotalEpisodes()).isEqualTo(68);
|
||||||
|
assertThat(result.getList().get(1).getDramaTitle()).isEqualTo("剧名二");
|
||||||
|
assertThat(result.getList().get(1).getEpisodeNumber()).isEqualTo(2);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<Page<WatchProgress>> pageCaptor = ArgumentCaptor.forClass(Page.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<Wrapper<WatchProgress>> wrapperCaptor =
|
||||||
|
ArgumentCaptor.forClass(Wrapper.class);
|
||||||
|
verify(progressMapper).selectPage(pageCaptor.capture(), wrapperCaptor.capture());
|
||||||
|
assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(1);
|
||||||
|
assertThat(pageCaptor.getValue().getSize()).isEqualTo(20);
|
||||||
|
assertThat(wrapperCaptor.getValue().getSqlSegment())
|
||||||
|
.contains("updated_at DESC")
|
||||||
|
.contains("id DESC");
|
||||||
|
verify(dramaService, times(1)).listByIds(List.of(12L, 13L));
|
||||||
|
verify(episodeService, times(1)).listByIds(List.of(88L, 99L));
|
||||||
|
}
|
||||||
|
|
||||||
|
private WatchProgress progress(Long dramaId, Long episodeId) {
|
||||||
|
WatchProgress progress = new WatchProgress();
|
||||||
|
progress.setUserId(1001L);
|
||||||
|
progress.setDramaId(dramaId);
|
||||||
|
progress.setEpisodeId(episodeId);
|
||||||
|
progress.setPositionMs(80_000L);
|
||||||
|
progress.setDurationMs(120_000L);
|
||||||
|
progress.setFinished(0);
|
||||||
|
progress.setPlayCount(1);
|
||||||
|
progress.setUpdatedAt(LocalDateTime.of(2026, 10, 22, 10, 0));
|
||||||
|
return progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Drama drama(Long id, String title, int totalEpisodes) {
|
||||||
|
Drama drama = new Drama();
|
||||||
|
drama.setId(id);
|
||||||
|
drama.setTitle(title);
|
||||||
|
drama.setCoverUrl("/files/cover/" + id + ".jpg");
|
||||||
|
drama.setTotalEpisodes(totalEpisodes);
|
||||||
|
return drama;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DramaEpisode episode(Long id, int epNo) {
|
||||||
|
DramaEpisode episode = new DramaEpisode();
|
||||||
|
episode.setId(id);
|
||||||
|
episode.setEpNo(epNo);
|
||||||
|
return episode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,10 @@
|
|||||||
<groupId>com.fruitshorts</groupId>
|
<groupId>com.fruitshorts</groupId>
|
||||||
<artifactId>fruitshorts-module-system</artifactId>
|
<artifactId>fruitshorts-module-system</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fruitshorts</groupId>
|
||||||
|
<artifactId>fruitshorts-module-media</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
+32
-1
@@ -15,6 +15,8 @@ import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
|||||||
import com.fruitshorts.content.enums.ContentStatus;
|
import com.fruitshorts.content.enums.ContentStatus;
|
||||||
import com.fruitshorts.content.vo.EpisodeRespVO;
|
import com.fruitshorts.content.vo.EpisodeRespVO;
|
||||||
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
|
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
|
||||||
|
import com.fruitshorts.media.service.EpisodeTranscodeBindingService;
|
||||||
|
import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -29,7 +31,7 @@ import java.util.Objects;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class EpisodeService {
|
public class EpisodeService implements EpisodeTranscodeCompletionPort {
|
||||||
|
|
||||||
private static final int DEFAULT_PAGE_INDEX = 1;
|
private static final int DEFAULT_PAGE_INDEX = 1;
|
||||||
private static final int DEFAULT_PAGE_SIZE = 100;
|
private static final int DEFAULT_PAGE_SIZE = 100;
|
||||||
@@ -37,6 +39,7 @@ public class EpisodeService {
|
|||||||
|
|
||||||
private final DramaEpisodeMapper dramaEpisodeMapper;
|
private final DramaEpisodeMapper dramaEpisodeMapper;
|
||||||
private final DramaService dramaService;
|
private final DramaService dramaService;
|
||||||
|
private final EpisodeTranscodeBindingService transcodeBindingService;
|
||||||
|
|
||||||
/** 后台:按短剧分页查询分集,status 在 SQL 层过滤;排序稳定(sort、ep_no)。 */
|
/** 后台:按短剧分页查询分集,status 在 SQL 层过滤;排序稳定(sort、ep_no)。 */
|
||||||
public PageResult<EpisodeRespVO> pageByDrama(Long dramaId, String status,
|
public PageResult<EpisodeRespVO> pageByDrama(Long dramaId, String status,
|
||||||
@@ -135,6 +138,7 @@ public class EpisodeService {
|
|||||||
episode.setLikeCount(nonNegativeOrZero(req.getLikeCount()));
|
episode.setLikeCount(nonNegativeOrZero(req.getLikeCount()));
|
||||||
episode.setVideoAssetId(0L);
|
episode.setVideoAssetId(0L);
|
||||||
dramaEpisodeMapper.insert(episode);
|
dramaEpisodeMapper.insert(episode);
|
||||||
|
bindTranscodeTask(episode);
|
||||||
dramaService.syncTotalEpisodes(req.getDramaId());
|
dramaService.syncTotalEpisodes(req.getDramaId());
|
||||||
return episode.getId();
|
return episode.getId();
|
||||||
}
|
}
|
||||||
@@ -152,6 +156,7 @@ public class EpisodeService {
|
|||||||
episode.setStatus(req.getStatus());
|
episode.setStatus(req.getStatus());
|
||||||
}
|
}
|
||||||
dramaEpisodeMapper.updateById(episode);
|
dramaEpisodeMapper.updateById(episode);
|
||||||
|
bindTranscodeTask(episode);
|
||||||
if (!req.getDramaId().equals(episode.getDramaId())) {
|
if (!req.getDramaId().equals(episode.getDramaId())) {
|
||||||
dramaService.syncTotalEpisodes(req.getDramaId());
|
dramaService.syncTotalEpisodes(req.getDramaId());
|
||||||
}
|
}
|
||||||
@@ -252,6 +257,32 @@ public class EpisodeService {
|
|||||||
episode.setSort(req.getSort() == null ? (req.getNumber() == null ? 0 : req.getNumber()) : req.getSort());
|
episode.setSort(req.getSort() == null ? (req.getNumber() == null ? 0 : req.getNumber()) : req.getSort());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void bindTranscodeTask(DramaEpisode episode) {
|
||||||
|
Long assetId = transcodeBindingService.bind(
|
||||||
|
episode.getId(), episode.getDramaId(), episode.getVideoUrl());
|
||||||
|
if (assetId != null && assetId > 0
|
||||||
|
&& !Objects.equals(assetId, episode.getVideoAssetId())) {
|
||||||
|
episode.setVideoAssetId(assetId);
|
||||||
|
dramaEpisodeMapper.updateById(episode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void markEpisodeReady(Long episodeId, String hlsUrl, Integer durationSec) {
|
||||||
|
if (episodeId == null || episodeId <= 0 || !StringUtils.hasText(hlsUrl)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var update = Wrappers.<DramaEpisode>lambdaUpdate()
|
||||||
|
.eq(DramaEpisode::getId, episodeId)
|
||||||
|
.set(DramaEpisode::getHlsUrl, hlsUrl)
|
||||||
|
.set(DramaEpisode::getTranscodeStatus, "success");
|
||||||
|
if (durationSec != null && durationSec > 0) {
|
||||||
|
update.set(DramaEpisode::getDurationSec, durationSec);
|
||||||
|
}
|
||||||
|
dramaEpisodeMapper.update(null, update);
|
||||||
|
}
|
||||||
|
|
||||||
private EpisodeRespVO toResp(DramaEpisode episode) {
|
private EpisodeRespVO toResp(DramaEpisode episode) {
|
||||||
EpisodeRespVO vo = new EpisodeRespVO();
|
EpisodeRespVO vo = new EpisodeRespVO();
|
||||||
vo.setId(episode.getId());
|
vo.setId(episode.getId());
|
||||||
|
|||||||
+48
-1
@@ -3,6 +3,7 @@
|
|||||||
package com.fruitshorts.content.service;
|
package com.fruitshorts.content.service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.fruitshorts.common.pojo.PageResult;
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
@@ -10,6 +11,8 @@ import com.fruitshorts.content.dal.entity.Drama;
|
|||||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
||||||
import com.fruitshorts.content.vo.EpisodeRespVO;
|
import com.fruitshorts.content.vo.EpisodeRespVO;
|
||||||
|
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
|
||||||
|
import com.fruitshorts.media.service.EpisodeTranscodeBindingService;
|
||||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -20,7 +23,9 @@ import java.util.List;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.BDDMockito.given;
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
@@ -33,6 +38,7 @@ class EpisodeServicePageTest {
|
|||||||
|
|
||||||
private DramaEpisodeMapper dramaEpisodeMapper;
|
private DramaEpisodeMapper dramaEpisodeMapper;
|
||||||
private DramaService dramaService;
|
private DramaService dramaService;
|
||||||
|
private EpisodeTranscodeBindingService transcodeBindingService;
|
||||||
private EpisodeService episodeService;
|
private EpisodeService episodeService;
|
||||||
|
|
||||||
@BeforeAll
|
@BeforeAll
|
||||||
@@ -45,10 +51,51 @@ class EpisodeServicePageTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
dramaEpisodeMapper = mock(DramaEpisodeMapper.class);
|
dramaEpisodeMapper = mock(DramaEpisodeMapper.class);
|
||||||
dramaService = mock(DramaService.class);
|
dramaService = mock(DramaService.class);
|
||||||
episodeService = new EpisodeService(dramaEpisodeMapper, dramaService);
|
transcodeBindingService = mock(EpisodeTranscodeBindingService.class);
|
||||||
|
episodeService = new EpisodeService(
|
||||||
|
dramaEpisodeMapper, dramaService, transcodeBindingService);
|
||||||
given(dramaService.getExisting(DRAMA_ID)).willReturn(new Drama());
|
given(dramaService.getExisting(DRAMA_ID)).willReturn(new Drama());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_bind_media_asset_and_transcode_task_when_episode_video_is_saved() {
|
||||||
|
Drama drama = new Drama();
|
||||||
|
drama.setId(DRAMA_ID);
|
||||||
|
given(dramaService.getExisting(DRAMA_ID)).willReturn(drama);
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
DramaEpisode episode = invocation.getArgument(0);
|
||||||
|
episode.setId(31L);
|
||||||
|
return 1;
|
||||||
|
}).when(dramaEpisodeMapper).insert(any(DramaEpisode.class));
|
||||||
|
given(transcodeBindingService.bind(
|
||||||
|
31L, DRAMA_ID, "/files/episode/202610/video.mp4")).willReturn(88L);
|
||||||
|
EpisodeSaveReqVO req = new EpisodeSaveReqVO();
|
||||||
|
req.setDramaId(DRAMA_ID);
|
||||||
|
req.setNumber(2);
|
||||||
|
req.setVideoUrl("/files/episode/202610/video.mp4");
|
||||||
|
|
||||||
|
Long id = episodeService.create(req);
|
||||||
|
|
||||||
|
assertThat(id).isEqualTo(31L);
|
||||||
|
verify(transcodeBindingService).bind(
|
||||||
|
31L, DRAMA_ID, "/files/episode/202610/video.mp4");
|
||||||
|
ArgumentCaptor<DramaEpisode> updated = ArgumentCaptor.forClass(DramaEpisode.class);
|
||||||
|
verify(dramaEpisodeMapper).updateById(updated.capture());
|
||||||
|
assertThat(updated.getValue().getVideoAssetId()).isEqualTo(88L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_write_hls_url_and_success_status_when_transcode_finishes() {
|
||||||
|
episodeService.markEpisodeReady(31L, "/files/transcoded/88/master.m3u8", 4);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<LambdaUpdateWrapper<DramaEpisode>> captor =
|
||||||
|
ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||||||
|
verify(dramaEpisodeMapper).update(any(), captor.capture());
|
||||||
|
assertThat(captor.getValue().getSqlSet()).contains("hls_url");
|
||||||
|
assertThat(captor.getValue().getSqlSet()).contains("transcode_status");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_use_default_page_size_100_when_page_size_omitted() {
|
void should_use_default_page_size_100_when_page_size_omitted() {
|
||||||
stubSelectPage(0L, List.of());
|
stubSelectPage(0L, List.of());
|
||||||
|
|||||||
@@ -21,5 +21,9 @@
|
|||||||
<groupId>com.fruitshorts</groupId>
|
<groupId>com.fruitshorts</groupId>
|
||||||
<artifactId>fruitshorts-framework</artifactId>
|
<artifactId>fruitshorts-framework</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fruitshorts</groupId>
|
||||||
|
<artifactId>fruitshorts-module-system</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.media.config;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台可配置的转码档位与雪碧图参数。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class MediaTranscodeProfile {
|
||||||
|
|
||||||
|
public static final int DEFAULT_SEGMENT_SECONDS = 6;
|
||||||
|
public static final int DEFAULT_SPRITE_INTERVAL = 1;
|
||||||
|
public static final int DEFAULT_SPRITE_COLUMNS = 10;
|
||||||
|
public static final int DEFAULT_THUMB_WIDTH = 160;
|
||||||
|
|
||||||
|
private boolean enabled = true;
|
||||||
|
private int segmentSeconds = DEFAULT_SEGMENT_SECONDS;
|
||||||
|
private List<Rung> ladder = defaultLadder();
|
||||||
|
private Sprite sprite = new Sprite();
|
||||||
|
|
||||||
|
/** 默认四档与第一阶段 yml 保持一致。 */
|
||||||
|
public static List<Rung> defaultLadder() {
|
||||||
|
List<Rung> ladder = new ArrayList<>(4);
|
||||||
|
ladder.add(new Rung("360p", 640, 360, 400, 64, true));
|
||||||
|
ladder.add(new Rung("480p", 854, 480, 800, 64, true));
|
||||||
|
ladder.add(new Rung("720p", 1280, 720, 1600, 96, true));
|
||||||
|
ladder.add(new Rung("1080p", 1920, 1080, 3200, 128, true));
|
||||||
|
return ladder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一档清晰度。 */
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class Rung {
|
||||||
|
private String quality;
|
||||||
|
private int width;
|
||||||
|
private int height;
|
||||||
|
private int videoBitrateKbps;
|
||||||
|
private int audioBitrateKbps;
|
||||||
|
private boolean enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 雪碧图采样参数。 */
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
public static class Sprite {
|
||||||
|
private boolean enabled = true;
|
||||||
|
private int intervalSeconds = DEFAULT_SPRITE_INTERVAL;
|
||||||
|
private int columns = DEFAULT_SPRITE_COLUMNS;
|
||||||
|
private int thumbWidth = DEFAULT_THUMB_WIDTH;
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.media.config;
|
||||||
|
|
||||||
|
import com.fruitshorts.common.exception.ErrorCode;
|
||||||
|
import com.fruitshorts.common.exception.ServiceException;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转码配置合法性校验。
|
||||||
|
*/
|
||||||
|
public final class MediaTranscodeProfileValidator {
|
||||||
|
|
||||||
|
private static final int MIN_SEGMENT_SECONDS = 2;
|
||||||
|
private static final int MAX_SEGMENT_SECONDS = 10;
|
||||||
|
private static final int MIN_SPRITE_INTERVAL = 1;
|
||||||
|
private static final int MAX_SPRITE_INTERVAL = 10;
|
||||||
|
private static final int MIN_COLUMNS = 5;
|
||||||
|
private static final int MAX_COLUMNS = 20;
|
||||||
|
private static final int MIN_THUMB_WIDTH = 120;
|
||||||
|
private static final int MAX_THUMB_WIDTH = 240;
|
||||||
|
private static final int MAX_QUALITY_LENGTH = 16;
|
||||||
|
private static final int EVEN_DIVISOR = 2;
|
||||||
|
|
||||||
|
private MediaTranscodeProfileValidator() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 非法配置转成统一 400;调用方必须在持久化前执行。 */
|
||||||
|
public static void validate(MediaTranscodeProfile profile) {
|
||||||
|
if (profile == null) {
|
||||||
|
throw invalid("转码配置不能为空");
|
||||||
|
}
|
||||||
|
validateSegment(profile.getSegmentSeconds());
|
||||||
|
validateLadder(profile.getLadder());
|
||||||
|
validateSprite(profile.getSprite());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateSegment(int value) {
|
||||||
|
if (value < MIN_SEGMENT_SECONDS || value > MAX_SEGMENT_SECONDS) {
|
||||||
|
throw invalid("segmentSeconds 必须在 2-10 之间");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateLadder(Iterable<MediaTranscodeProfile.Rung> ladder) {
|
||||||
|
if (ladder == null) {
|
||||||
|
throw invalid("ladder 不能为空");
|
||||||
|
}
|
||||||
|
Set<String> qualities = new HashSet<>();
|
||||||
|
boolean hasEnabled = false;
|
||||||
|
for (MediaTranscodeProfile.Rung rung : ladder) {
|
||||||
|
validateRung(rung, qualities);
|
||||||
|
hasEnabled = hasEnabled || rung.isEnabled();
|
||||||
|
}
|
||||||
|
if (!hasEnabled) {
|
||||||
|
throw invalid("至少需要启用一档清晰度");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateRung(MediaTranscodeProfile.Rung rung,
|
||||||
|
Set<String> qualities) {
|
||||||
|
if (rung == null || rung.getQuality() == null || rung.getQuality().isBlank()) {
|
||||||
|
throw invalid("quality 不能为空");
|
||||||
|
}
|
||||||
|
String quality = rung.getQuality().trim();
|
||||||
|
if (quality.length() > MAX_QUALITY_LENGTH || !qualities.add(quality)) {
|
||||||
|
throw invalid("quality 必须唯一且不超过 16 个字符");
|
||||||
|
}
|
||||||
|
if (rung.getWidth() <= 0 || rung.getWidth() % EVEN_DIVISOR != 0
|
||||||
|
|| rung.getHeight() <= 0 || rung.getHeight() % EVEN_DIVISOR != 0) {
|
||||||
|
throw invalid("宽高必须为正偶数");
|
||||||
|
}
|
||||||
|
if (rung.getVideoBitrateKbps() <= 0 || rung.getAudioBitrateKbps() <= 0) {
|
||||||
|
throw invalid("音视频码率必须大于 0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateSprite(MediaTranscodeProfile.Sprite sprite) {
|
||||||
|
if (sprite == null) {
|
||||||
|
throw invalid("sprite 配置不能为空");
|
||||||
|
}
|
||||||
|
if (sprite.getIntervalSeconds() < MIN_SPRITE_INTERVAL
|
||||||
|
|| sprite.getIntervalSeconds() > MAX_SPRITE_INTERVAL) {
|
||||||
|
throw invalid("intervalSeconds 必须在 1-10 之间");
|
||||||
|
}
|
||||||
|
if (sprite.getColumns() < MIN_COLUMNS || sprite.getColumns() > MAX_COLUMNS) {
|
||||||
|
throw invalid("columns 必须在 5-20 之间");
|
||||||
|
}
|
||||||
|
if (sprite.getThumbWidth() < MIN_THUMB_WIDTH
|
||||||
|
|| sprite.getThumbWidth() > MAX_THUMB_WIDTH) {
|
||||||
|
throw invalid("thumbWidth 必须在 120-240 之间");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceException invalid(String message) {
|
||||||
|
return new ServiceException(ErrorCode.BAD_REQUEST, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -60,6 +60,8 @@ public class TranscodeProperties {
|
|||||||
private int height;
|
private int height;
|
||||||
private int videoBitrateKbps;
|
private int videoBitrateKbps;
|
||||||
private int audioBitrateKbps;
|
private int audioBitrateKbps;
|
||||||
|
/** 单档开关;yml 未填写时保持启用,兼容既有四档配置。 */
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
public Rung() {
|
public Rung() {
|
||||||
}
|
}
|
||||||
@@ -71,5 +73,11 @@ public class TranscodeProperties {
|
|||||||
this.videoBitrateKbps = videoBitrateKbps;
|
this.videoBitrateKbps = videoBitrateKbps;
|
||||||
this.audioBitrateKbps = audioBitrateKbps;
|
this.audioBitrateKbps = audioBitrateKbps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Rung(String quality, int width, int height, int videoBitrateKbps,
|
||||||
|
int audioBitrateKbps, boolean enabled) {
|
||||||
|
this(quality, width, height, videoBitrateKbps, audioBitrateKbps);
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.media.dal.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 单集缩略图雪碧图与 VTT 时间轴。 */
|
||||||
|
@Data
|
||||||
|
@TableName("media_thumbnail")
|
||||||
|
public class MediaThumbnail {
|
||||||
|
|
||||||
|
public static final String STATUS_READY = "READY";
|
||||||
|
public static final String STATUS_FAILED = "FAILED";
|
||||||
|
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
private Long episodeId;
|
||||||
|
private Long dramaId;
|
||||||
|
private String vttObjectKey;
|
||||||
|
private String spritePrefix;
|
||||||
|
private Integer intervalSec;
|
||||||
|
private Integer columns;
|
||||||
|
private Integer rows;
|
||||||
|
private Integer thumbWidth;
|
||||||
|
private Integer thumbHeight;
|
||||||
|
private Integer frameCount;
|
||||||
|
private Integer durationSec;
|
||||||
|
private Long sizeBytes;
|
||||||
|
private String status;
|
||||||
|
private String errorMsg;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@TableLogic
|
||||||
|
private Integer deleted;
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.media.dal.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩略图时间轴数据访问。
|
||||||
|
*/
|
||||||
|
public interface MediaThumbnailMapper extends BaseMapper<MediaThumbnail> {
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.media.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.fruitshorts.media.dal.entity.MediaAsset;
|
||||||
|
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把分集视频地址对应的媒资与转码任务绑定到 dramaId / episodeId。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class EpisodeTranscodeBindingService {
|
||||||
|
|
||||||
|
private final MediaAssetMapper mediaAssetMapper;
|
||||||
|
private final MediaStorageService storageService;
|
||||||
|
private final TranscodeService transcodeService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按视频 objectKey 查找媒资并绑定任务。
|
||||||
|
*
|
||||||
|
* @return 命中的 mediaAssetId;外部视频或未上传媒资返回 null
|
||||||
|
*/
|
||||||
|
public Long bind(Long episodeId, Long dramaId, String videoUrl) {
|
||||||
|
if (episodeId == null || episodeId <= 0
|
||||||
|
|| dramaId == null || dramaId <= 0
|
||||||
|
|| !StringUtils.hasText(videoUrl)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String objectKey = storageService.objectKeyOf(videoUrl);
|
||||||
|
if (!StringUtils.hasText(objectKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MediaAsset asset = mediaAssetMapper.selectOne(Wrappers.<MediaAsset>lambdaQuery()
|
||||||
|
.eq(MediaAsset::getObjectKey, objectKey)
|
||||||
|
.last("limit 1"));
|
||||||
|
if (asset == null) {
|
||||||
|
log.warn("分集 {} 的视频地址未找到 media_asset:{}", episodeId, videoUrl);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
transcodeService.bindToEpisode(
|
||||||
|
asset.getId(), dramaId, episodeId, asset.getSourceUrl());
|
||||||
|
return asset.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.media.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转码完成后回填分集 HLS 状态的跨域端口,由 content 域实现。
|
||||||
|
*/
|
||||||
|
public interface EpisodeTranscodeCompletionPort {
|
||||||
|
|
||||||
|
/** 写入分集 HLS 地址并标记转码成功。 */
|
||||||
|
void markEpisodeReady(Long episodeId, String hlsUrl, Integer durationSec);
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.media.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fruitshorts.common.exception.ErrorCode;
|
||||||
|
import com.fruitshorts.common.exception.ServiceException;
|
||||||
|
import com.fruitshorts.media.config.MediaTranscodeProfile;
|
||||||
|
import com.fruitshorts.media.config.MediaTranscodeProfileValidator;
|
||||||
|
import com.fruitshorts.media.config.TranscodeProperties;
|
||||||
|
import com.fruitshorts.system.service.ConfigService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转码配置读写:后台立即落库,worker 每 30 秒感知一次;异常配置回退 yml。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MediaTranscodeProfileService {
|
||||||
|
|
||||||
|
public static final String CONFIG_KEY = "media.transcode.profile";
|
||||||
|
public static final Duration CACHE_TTL = Duration.ofSeconds(30);
|
||||||
|
|
||||||
|
private final ConfigService configService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final TranscodeProperties defaults;
|
||||||
|
private volatile CachedProfile cached;
|
||||||
|
|
||||||
|
/** 读取当前有效配置;缺省或非法时回退 yml 默认。 */
|
||||||
|
public MediaTranscodeProfile currentProfile() {
|
||||||
|
CachedProfile current = cached;
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (current != null && current.expiresAt().isAfter(now)) {
|
||||||
|
return current.profile();
|
||||||
|
}
|
||||||
|
MediaTranscodeProfile loaded = loadProfile();
|
||||||
|
cached = new CachedProfile(loaded, now.plus(CACHE_TTL));
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台读取不做 30 秒缓存,确保直接改库后刷新页面即可看到最新值。 */
|
||||||
|
public MediaTranscodeProfile readConfiguredProfile() {
|
||||||
|
return loadProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存前校验;非法配置不会触发 sys_config 写操作。 */
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public MediaTranscodeProfile save(MediaTranscodeProfile profile) {
|
||||||
|
MediaTranscodeProfileValidator.validate(profile);
|
||||||
|
String json = writeJson(profile);
|
||||||
|
configService.update(Map.of(CONFIG_KEY, json));
|
||||||
|
cached = new CachedProfile(profile, Instant.now().plus(CACHE_TTL));
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** yml/代码默认配置,后台缺省值也使用这一份。 */
|
||||||
|
public MediaTranscodeProfile defaultProfile() {
|
||||||
|
MediaTranscodeProfile profile = new MediaTranscodeProfile();
|
||||||
|
profile.setEnabled(true);
|
||||||
|
profile.setSegmentSeconds(defaults.getSegmentSeconds());
|
||||||
|
profile.setLadder(defaults.getLadder().stream()
|
||||||
|
.map(rung -> new MediaTranscodeProfile.Rung(
|
||||||
|
rung.getQuality(), rung.getWidth(), rung.getHeight(),
|
||||||
|
rung.getVideoBitrateKbps(), rung.getAudioBitrateKbps(),
|
||||||
|
rung.isEnabled()))
|
||||||
|
.toList());
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** worker 的 ffmpeg 路径仍以 yml 为运行环境配置。 */
|
||||||
|
public String ffmpegPath() {
|
||||||
|
return defaults.getFfmpegPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String ffprobePath() {
|
||||||
|
return defaults.getFfprobePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int posterSecond() {
|
||||||
|
return defaults.getPosterSecond();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int timeoutSeconds() {
|
||||||
|
return defaults.getTimeoutSeconds();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MediaTranscodeProfile loadProfile() {
|
||||||
|
try {
|
||||||
|
String json = configService.get(CONFIG_KEY, "");
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return defaultProfile();
|
||||||
|
}
|
||||||
|
MediaTranscodeProfile profile =
|
||||||
|
objectMapper.readValue(json, MediaTranscodeProfile.class);
|
||||||
|
MediaTranscodeProfileValidator.validate(profile);
|
||||||
|
return profile;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("转码配置无效,回退 yml 默认:{}", ex.getMessage());
|
||||||
|
return defaultProfile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String writeJson(MediaTranscodeProfile profile) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(profile);
|
||||||
|
} catch (JsonProcessingException ex) {
|
||||||
|
throw new ServiceException(ErrorCode.INTERNAL_ERROR, "转码配置序列化失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CachedProfile(MediaTranscodeProfile profile, Instant expiresAt) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.media.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||||
|
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||||
|
import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩略图登记使用独立事务,DB 异常不得回滚主 HLS 成功事务。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ThumbnailRecordService {
|
||||||
|
|
||||||
|
private final MediaThumbnailMapper mediaThumbnailMapper;
|
||||||
|
|
||||||
|
/** 写入或覆盖单集缩略图时间轴记录。 */
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
|
||||||
|
public void upsert(TranscodeTask task, MediaThumbnail thumbnail) {
|
||||||
|
Long episodeId = task.getEpisodeId();
|
||||||
|
if (episodeId == null || episodeId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MediaThumbnail existing = mediaThumbnailMapper.selectOne(
|
||||||
|
Wrappers.<MediaThumbnail>lambdaQuery()
|
||||||
|
.eq(MediaThumbnail::getEpisodeId, episodeId)
|
||||||
|
.last("limit 1"));
|
||||||
|
thumbnail.setDramaId(task.getDramaId() == null ? 0L : task.getDramaId());
|
||||||
|
thumbnail.setEpisodeId(episodeId);
|
||||||
|
if (existing == null) {
|
||||||
|
mediaThumbnailMapper.insert(thumbnail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thumbnail.setId(existing.getId());
|
||||||
|
mediaThumbnailMapper.updateById(thumbnail);
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.media.service;
|
||||||
|
|
||||||
|
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 READY 缩略图记录解析成公开且已签名的 VTT 地址。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ThumbnailUrlResolver {
|
||||||
|
|
||||||
|
private final TranscodeService transcodeService;
|
||||||
|
private final MediaStorageService storageService;
|
||||||
|
private final PlaybackSigner signer;
|
||||||
|
|
||||||
|
/** 单集播放使用。 */
|
||||||
|
public String resolve(Long episodeId) {
|
||||||
|
return toUrl(transcodeService.findReadyThumbnail(episodeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情列表一次批量解析,禁止 N+1。 */
|
||||||
|
public Map<Long, String> resolveAll(Collection<Long> episodeIds) {
|
||||||
|
Map<Long, MediaThumbnail> thumbnails =
|
||||||
|
transcodeService.listReadyThumbnails(episodeIds);
|
||||||
|
Map<Long, String> result = new LinkedHashMap<>();
|
||||||
|
thumbnails.forEach((episodeId, thumbnail) -> {
|
||||||
|
String url = toUrl(thumbnail);
|
||||||
|
if (url != null) {
|
||||||
|
result.put(episodeId, url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toUrl(MediaThumbnail thumbnail) {
|
||||||
|
if (thumbnail == null || !StringUtils.hasText(thumbnail.getVttObjectKey())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String publicUrl = storageService.toPublicUrl(thumbnail.getVttObjectKey());
|
||||||
|
String signed = signer.sign(publicUrl);
|
||||||
|
return StringUtils.hasText(signed) ? signed : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
@@ -6,10 +6,13 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
|||||||
import com.fruitshorts.common.exception.ErrorCode;
|
import com.fruitshorts.common.exception.ErrorCode;
|
||||||
import com.fruitshorts.common.exception.ServiceException;
|
import com.fruitshorts.common.exception.ServiceException;
|
||||||
import com.fruitshorts.media.dal.entity.MediaRendition;
|
import com.fruitshorts.media.dal.entity.MediaRendition;
|
||||||
|
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||||
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||||
import com.fruitshorts.media.dal.mapper.MediaRenditionMapper;
|
import com.fruitshorts.media.dal.mapper.MediaRenditionMapper;
|
||||||
|
import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper;
|
||||||
import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper;
|
import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -17,6 +20,7 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转码任务编排。
|
* 转码任务编排。
|
||||||
@@ -26,6 +30,7 @@ import java.util.Map;
|
|||||||
* 与在线服务物理隔离,避免转码抢占播放链路的 CPU。
|
* 与在线服务物理隔离,避免转码抢占播放链路的 CPU。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class TranscodeService {
|
public class TranscodeService {
|
||||||
|
|
||||||
@@ -38,6 +43,8 @@ public class TranscodeService {
|
|||||||
|
|
||||||
private final TranscodeTaskMapper transcodeTaskMapper;
|
private final TranscodeTaskMapper transcodeTaskMapper;
|
||||||
private final MediaRenditionMapper mediaRenditionMapper;
|
private final MediaRenditionMapper mediaRenditionMapper;
|
||||||
|
private final MediaThumbnailMapper mediaThumbnailMapper;
|
||||||
|
private final ThumbnailRecordService thumbnailRecordService;
|
||||||
|
|
||||||
/** 上传完成后入队;同一媒资重复入队时复用未完成的任务,避免重复转码。 */
|
/** 上传完成后入队;同一媒资重复入队时复用未完成的任务,避免重复转码。 */
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
@@ -47,6 +54,7 @@ public class TranscodeService {
|
|||||||
.in(TranscodeTask::getStatus, List.of(STATUS_QUEUED, STATUS_RUNNING))
|
.in(TranscodeTask::getStatus, List.of(STATUS_QUEUED, STATUS_RUNNING))
|
||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
|
updateTaskBinding(existing, dramaId, episodeId, sourceUrl);
|
||||||
return existing.getId();
|
return existing.getId();
|
||||||
}
|
}
|
||||||
TranscodeTask task = new TranscodeTask();
|
TranscodeTask task = new TranscodeTask();
|
||||||
@@ -63,10 +71,49 @@ public class TranscodeService {
|
|||||||
return task.getId();
|
return task.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分集保存视频地址后绑定任务;成功任务复用并同步回填已有的零绑定产物。
|
||||||
|
*/
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public Long bindToEpisode(Long mediaAssetId, Long dramaId, Long episodeId, String sourceUrl) {
|
||||||
|
if (mediaAssetId == null || mediaAssetId <= 0
|
||||||
|
|| dramaId == null || dramaId <= 0
|
||||||
|
|| episodeId == null || episodeId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TranscodeTask latest = transcodeTaskMapper.selectOne(
|
||||||
|
Wrappers.<TranscodeTask>lambdaQuery()
|
||||||
|
.eq(TranscodeTask::getMediaAssetId, mediaAssetId)
|
||||||
|
.orderByDesc(TranscodeTask::getId)
|
||||||
|
.last("limit 1"));
|
||||||
|
if (latest == null
|
||||||
|
|| STATUS_FAILED.equals(latest.getStatus())
|
||||||
|
|| "CANCELED".equals(latest.getStatus())) {
|
||||||
|
return enqueue(mediaAssetId, dramaId, episodeId, sourceUrl);
|
||||||
|
}
|
||||||
|
if (latest.getEpisodeId() != null && latest.getEpisodeId() > 0
|
||||||
|
&& !latest.getEpisodeId().equals(episodeId)) {
|
||||||
|
log.warn("媒资 {} 已绑定分集 {},拒绝改绑到 {}", mediaAssetId,
|
||||||
|
latest.getEpisodeId(), episodeId);
|
||||||
|
return latest.getId();
|
||||||
|
}
|
||||||
|
updateTaskBinding(latest, dramaId, episodeId, sourceUrl);
|
||||||
|
if (STATUS_SUCCESS.equals(latest.getStatus())) {
|
||||||
|
mediaRenditionMapper.update(null, Wrappers.<MediaRendition>lambdaUpdate()
|
||||||
|
.eq(MediaRendition::getMediaAssetId, mediaAssetId)
|
||||||
|
.and(wrapper -> wrapper.eq(MediaRendition::getEpisodeId, 0L)
|
||||||
|
.or().isNull(MediaRendition::getEpisodeId))
|
||||||
|
.set(MediaRendition::getDramaId, dramaId)
|
||||||
|
.set(MediaRendition::getEpisodeId, episodeId));
|
||||||
|
}
|
||||||
|
return latest.getId();
|
||||||
|
}
|
||||||
|
|
||||||
/** worker 认领任务。并发下依赖状态条件更新,抢不到返回 null。 */
|
/** worker 认领任务。并发下依赖状态条件更新,抢不到返回 null。 */
|
||||||
public TranscodeTask claimNext() {
|
public TranscodeTask claimNext() {
|
||||||
TranscodeTask candidate = transcodeTaskMapper.selectOne(Wrappers.<TranscodeTask>lambdaQuery()
|
TranscodeTask candidate = transcodeTaskMapper.selectOne(Wrappers.<TranscodeTask>lambdaQuery()
|
||||||
.eq(TranscodeTask::getStatus, STATUS_QUEUED)
|
.eq(TranscodeTask::getStatus, STATUS_QUEUED)
|
||||||
|
.gt(TranscodeTask::getEpisodeId, 0)
|
||||||
.orderByAsc(TranscodeTask::getId)
|
.orderByAsc(TranscodeTask::getId)
|
||||||
.last("limit 1"));
|
.last("limit 1"));
|
||||||
if (candidate == null) {
|
if (candidate == null) {
|
||||||
@@ -75,6 +122,7 @@ public class TranscodeService {
|
|||||||
int updated = transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate()
|
int updated = transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate()
|
||||||
.eq(TranscodeTask::getId, candidate.getId())
|
.eq(TranscodeTask::getId, candidate.getId())
|
||||||
.eq(TranscodeTask::getStatus, STATUS_QUEUED)
|
.eq(TranscodeTask::getStatus, STATUS_QUEUED)
|
||||||
|
.gt(TranscodeTask::getEpisodeId, 0)
|
||||||
.set(TranscodeTask::getStatus, STATUS_RUNNING)
|
.set(TranscodeTask::getStatus, STATUS_RUNNING)
|
||||||
.set(TranscodeTask::getStartedAt, LocalDateTime.now()));
|
.set(TranscodeTask::getStartedAt, LocalDateTime.now()));
|
||||||
return updated == 1 ? candidate : null;
|
return updated == 1 ? candidate : null;
|
||||||
@@ -82,6 +130,13 @@ public class TranscodeService {
|
|||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void succeed(Long taskId, List<MediaRendition> renditions) {
|
public void succeed(Long taskId, List<MediaRendition> renditions) {
|
||||||
|
succeed(taskId, renditions, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主转码成功:登记码率产物、缩略图结果并完成任务。 */
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void succeed(Long taskId, List<MediaRendition> renditions,
|
||||||
|
MediaThumbnail thumbnail) {
|
||||||
TranscodeTask task = getExisting(taskId);
|
TranscodeTask task = getExisting(taskId);
|
||||||
for (MediaRendition rendition : renditions) {
|
for (MediaRendition rendition : renditions) {
|
||||||
rendition.setMediaAssetId(task.getMediaAssetId());
|
rendition.setMediaAssetId(task.getMediaAssetId());
|
||||||
@@ -90,9 +145,21 @@ public class TranscodeService {
|
|||||||
rendition.setStatus("ready");
|
rendition.setStatus("ready");
|
||||||
mediaRenditionMapper.insert(rendition);
|
mediaRenditionMapper.insert(rendition);
|
||||||
}
|
}
|
||||||
|
String thumbnailError = "";
|
||||||
|
if (thumbnail != null) {
|
||||||
|
try {
|
||||||
|
thumbnailRecordService.upsert(task, thumbnail);
|
||||||
|
thumbnailError = thumbnail.getErrorMsg() == null
|
||||||
|
? "" : thumbnail.getErrorMsg();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
thumbnailError = "缩略图登记失败:" + exceptionMessage(ex);
|
||||||
|
log.warn("缩略图登记失败 task={}:{}", taskId, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
task.setStatus(STATUS_SUCCESS);
|
task.setStatus(STATUS_SUCCESS);
|
||||||
task.setProgress(100);
|
task.setProgress(100);
|
||||||
task.setFinishedAt(LocalDateTime.now());
|
task.setFinishedAt(LocalDateTime.now());
|
||||||
|
task.setErrorMsg(thumbnailError);
|
||||||
transcodeTaskMapper.updateById(task);
|
transcodeTaskMapper.updateById(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +190,31 @@ public class TranscodeService {
|
|||||||
.orderByAsc(MediaRendition::getVideoBitrate));
|
.orderByAsc(MediaRendition::getVideoBitrate));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 播放侧查询单集 READY 缩略图轴。 */
|
||||||
|
public MediaThumbnail findReadyThumbnail(Long episodeId) {
|
||||||
|
if (episodeId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mediaThumbnailMapper.selectOne(Wrappers.<MediaThumbnail>lambdaQuery()
|
||||||
|
.eq(MediaThumbnail::getEpisodeId, episodeId)
|
||||||
|
.eq(MediaThumbnail::getStatus, MediaThumbnail.STATUS_READY)
|
||||||
|
.last("limit 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情页一次批量取回,避免按集 N+1。 */
|
||||||
|
public Map<Long, MediaThumbnail> listReadyThumbnails(Collection<Long> episodeIds) {
|
||||||
|
if (episodeIds == null || episodeIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
List<MediaThumbnail> rows = mediaThumbnailMapper.selectList(
|
||||||
|
Wrappers.<MediaThumbnail>lambdaQuery()
|
||||||
|
.in(MediaThumbnail::getEpisodeId, episodeIds.stream().sorted().toList())
|
||||||
|
.eq(MediaThumbnail::getStatus, MediaThumbnail.STATUS_READY));
|
||||||
|
Map<Long, MediaThumbnail> result = new LinkedHashMap<>();
|
||||||
|
rows.forEach(row -> result.put(row.getEpisodeId(), row));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转码看板数据。
|
* 转码看板数据。
|
||||||
*
|
*
|
||||||
@@ -154,6 +246,36 @@ public class TranscodeService {
|
|||||||
.eq(TranscodeTask::getStatus, status));
|
.eq(TranscodeTask::getStatus, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateTaskBinding(TranscodeTask task, Long dramaId,
|
||||||
|
Long episodeId, String sourceUrl) {
|
||||||
|
if (task == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean hasDrama = dramaId != null && dramaId > 0;
|
||||||
|
boolean hasEpisode = episodeId != null && episodeId > 0;
|
||||||
|
transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate()
|
||||||
|
.eq(TranscodeTask::getId, task.getId())
|
||||||
|
.set(hasDrama, TranscodeTask::getDramaId, dramaId)
|
||||||
|
.set(hasEpisode, TranscodeTask::getEpisodeId, episodeId)
|
||||||
|
.set(sourceUrl != null && !sourceUrl.isBlank(),
|
||||||
|
TranscodeTask::getSourceUrl, sourceUrl));
|
||||||
|
if (hasDrama) {
|
||||||
|
task.setDramaId(dramaId);
|
||||||
|
}
|
||||||
|
if (hasEpisode) {
|
||||||
|
task.setEpisodeId(episodeId);
|
||||||
|
}
|
||||||
|
if (sourceUrl != null && !sourceUrl.isBlank()) {
|
||||||
|
task.setSourceUrl(sourceUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String exceptionMessage(Exception exception) {
|
||||||
|
String message = exception.getMessage();
|
||||||
|
return message == null || message.isBlank()
|
||||||
|
? exception.getClass().getSimpleName() : message;
|
||||||
|
}
|
||||||
|
|
||||||
private TranscodeTask getExisting(Long id) {
|
private TranscodeTask getExisting(Long id) {
|
||||||
TranscodeTask task = id == null ? null : transcodeTaskMapper.selectById(id);
|
TranscodeTask task = id == null ? null : transcodeTaskMapper.selectById(id);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
|
|||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.media.service.transcode;
|
||||||
|
|
||||||
|
import com.fruitshorts.common.exception.ErrorCode;
|
||||||
|
import com.fruitshorts.common.exception.ServiceException;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从源视频生成雪碧图与 WebVTT;失败只返回 FAILED 元数据,不阻断 HLS。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class FfmpegThumbnailGenerator {
|
||||||
|
|
||||||
|
private static final String STATUS_READY = "READY";
|
||||||
|
private static final String STATUS_FAILED = "FAILED";
|
||||||
|
private static final String THUMBNAIL_DIR = "thumbnails";
|
||||||
|
private static final String VTT_FILE = "thumbnails.vtt";
|
||||||
|
private static final String SPRITE_PATTERN = "sprite_%03d.jpg";
|
||||||
|
private static final int STDERR_TAIL = 600;
|
||||||
|
private static final int JPEG_QUALITY = 3;
|
||||||
|
|
||||||
|
/** 生成缩略图轴;禁用时返回 null。 */
|
||||||
|
public TranscodeRunner.ProducedThumbnail generate(
|
||||||
|
TranscodeRunner.TranscodeRequest request, int durationSec) {
|
||||||
|
if (request.sprite() == null || !request.sprite().enabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return generateFiles(request, durationSec);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("缩略图生成失败 task={}:{}", request.taskId(), ex.getMessage());
|
||||||
|
return failed(request, durationSec, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TranscodeRunner.ProducedThumbnail generateFiles(
|
||||||
|
TranscodeRunner.TranscodeRequest request, int durationSec) throws IOException {
|
||||||
|
Path thumbnailDir = request.outputDir().resolve(THUMBNAIL_DIR);
|
||||||
|
createDirectories(thumbnailDir);
|
||||||
|
TranscodeRunner.SpriteOptions sprite = request.sprite();
|
||||||
|
int frames = ThumbnailVttBuilder.frameCount(durationSec, sprite.intervalSeconds());
|
||||||
|
int rows = ThumbnailVttBuilder.rows(frames, sprite.columns());
|
||||||
|
runFfmpeg(request, thumbnailDir, rows);
|
||||||
|
|
||||||
|
Path firstSprite = thumbnailDir.resolve(String.format(SPRITE_PATTERN, 1));
|
||||||
|
BufferedImage image = ImageIO.read(firstSprite.toFile());
|
||||||
|
if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) {
|
||||||
|
throw new ServiceException(ErrorCode.INTERNAL_ERROR, "ffmpeg 未产出有效雪碧图");
|
||||||
|
}
|
||||||
|
ThumbnailVttBuilder.Layout layout = new ThumbnailVttBuilder.Layout(
|
||||||
|
durationSec, sprite.intervalSeconds(), sprite.columns(), rows,
|
||||||
|
sprite.thumbWidth(), image.getHeight() / rows);
|
||||||
|
Path vtt = thumbnailDir.resolve(VTT_FILE);
|
||||||
|
Files.writeString(vtt, ThumbnailVttBuilder.build(layout), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
return new TranscodeRunner.ProducedThumbnail(
|
||||||
|
joinKey(request.objectKeyPrefix(), THUMBNAIL_DIR + "/" + VTT_FILE),
|
||||||
|
joinKey(request.objectKeyPrefix(), THUMBNAIL_DIR + "/sprite_"),
|
||||||
|
sprite.intervalSeconds(), sprite.columns(), rows, sprite.thumbWidth(),
|
||||||
|
image.getHeight() / rows, frames, durationSec, sizeOf(thumbnailDir),
|
||||||
|
STATUS_READY, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runFfmpeg(TranscodeRunner.TranscodeRequest request, Path dir, int rows) {
|
||||||
|
TranscodeRunner.SpriteOptions sprite = request.sprite();
|
||||||
|
String filter = "fps=1/" + sprite.intervalSeconds()
|
||||||
|
+ ",scale=" + sprite.thumbWidth() + ":-2"
|
||||||
|
+ ",tile=" + sprite.columns() + "x" + rows;
|
||||||
|
List<String> command = List.of(
|
||||||
|
request.ffmpegPath(), "-y",
|
||||||
|
"-i", request.sourceFile().toString(),
|
||||||
|
"-vf", filter,
|
||||||
|
"-q:v", String.valueOf(JPEG_QUALITY),
|
||||||
|
dir.resolve(SPRITE_PATTERN).toString());
|
||||||
|
runProcess(command, request.timeoutSeconds(), "生成缩略图失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
private TranscodeRunner.ProducedThumbnail failed(
|
||||||
|
TranscodeRunner.TranscodeRequest request, int durationSec, String message) {
|
||||||
|
TranscodeRunner.SpriteOptions sprite = request.sprite();
|
||||||
|
Path thumbnailDir = request.outputDir().resolve(THUMBNAIL_DIR);
|
||||||
|
int frames = ThumbnailVttBuilder.frameCount(durationSec, sprite.intervalSeconds());
|
||||||
|
int rows = ThumbnailVttBuilder.rows(frames, sprite.columns());
|
||||||
|
return new TranscodeRunner.ProducedThumbnail(
|
||||||
|
joinKey(request.objectKeyPrefix(), THUMBNAIL_DIR + "/" + VTT_FILE),
|
||||||
|
joinKey(request.objectKeyPrefix(), THUMBNAIL_DIR + "/sprite_"),
|
||||||
|
sprite.intervalSeconds(), sprite.columns(), rows, sprite.thumbWidth(), 0,
|
||||||
|
frames, durationSec, sizeOf(thumbnailDir), STATUS_FAILED,
|
||||||
|
message == null ? "缩略图生成失败" : message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String runProcess(List<String> command, int timeoutSeconds, String errorPrefix) {
|
||||||
|
Path logFile = null;
|
||||||
|
try {
|
||||||
|
logFile = Files.createTempFile("fruitshorts-thumbnail-", ".log");
|
||||||
|
Process process = new ProcessBuilder(command)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.redirectOutput(logFile.toFile())
|
||||||
|
.start();
|
||||||
|
if (!process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
throw new ServiceException(ErrorCode.INTERNAL_ERROR, errorPrefix + ":超时");
|
||||||
|
}
|
||||||
|
String output = Files.readString(logFile, StandardCharsets.UTF_8);
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
|
||||||
|
errorPrefix + ":" + tail(output));
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
} catch (ServiceException ex) {
|
||||||
|
throw ex;
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new ServiceException(ErrorCode.INTERNAL_ERROR,
|
||||||
|
errorPrefix + ":" + ex.getMessage());
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new ServiceException(ErrorCode.INTERNAL_ERROR, errorPrefix + ":被中断");
|
||||||
|
} finally {
|
||||||
|
deleteQuietly(logFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createDirectories(Path path) throws IOException {
|
||||||
|
Files.createDirectories(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long sizeOf(Path dir) {
|
||||||
|
if (!Files.isDirectory(dir)) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
try (Stream<Path> files = Files.walk(dir)) {
|
||||||
|
return files.filter(Files::isRegularFile)
|
||||||
|
.mapToLong(path -> {
|
||||||
|
try {
|
||||||
|
return Files.size(path);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
} catch (IOException ex) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteQuietly(Path path) {
|
||||||
|
if (path == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// 临时日志清理失败不影响业务结果
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String tail(String output) {
|
||||||
|
String trimmed = output == null ? "" : output.trim();
|
||||||
|
return trimmed.length() <= STDERR_TAIL
|
||||||
|
? trimmed : trimmed.substring(trimmed.length() - STDERR_TAIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String joinKey(String prefix, String relative) {
|
||||||
|
String cleanPrefix = prefix == null ? "" : prefix.replaceAll("/+$", "");
|
||||||
|
return cleanPrefix.isEmpty() ? relative : cleanPrefix + "/" + relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-3
@@ -35,6 +35,7 @@ public class FfmpegTranscodeRunner implements TranscodeRunner {
|
|||||||
private static final String VARIANT_PLAYLIST = "index.m3u8";
|
private static final String VARIANT_PLAYLIST = "index.m3u8";
|
||||||
private static final String POSTER_FILE = "poster.jpg";
|
private static final String POSTER_FILE = "poster.jpg";
|
||||||
private static final int STDERR_TAIL = 800;
|
private static final int STDERR_TAIL = 800;
|
||||||
|
private final FfmpegThumbnailGenerator thumbnailGenerator = new FfmpegThumbnailGenerator();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TranscodeResult run(TranscodeRequest request) {
|
public TranscodeResult run(TranscodeRequest request) {
|
||||||
@@ -51,10 +52,12 @@ public class FfmpegTranscodeRunner implements TranscodeRunner {
|
|||||||
}
|
}
|
||||||
writeMasterPlaylist(request, renditions);
|
writeMasterPlaylist(request, renditions);
|
||||||
ProducedRendition poster = extractPoster(request);
|
ProducedRendition poster = extractPoster(request);
|
||||||
log.info("转码完成 task={}:{} 档 HLS + 封面,时长 {}s",
|
ProducedThumbnail thumbnail = thumbnailGenerator.generate(request, durationSec);
|
||||||
request.taskId(), renditions.size(), durationSec);
|
log.info("转码完成 task={}:{} 档 HLS + 封面 + 缩略图状态={},时长 {}s",
|
||||||
|
request.taskId(), renditions.size(),
|
||||||
|
thumbnail == null ? "DISABLED" : thumbnail.status(), durationSec);
|
||||||
return new TranscodeResult(joinKey(request.objectKeyPrefix(), MASTER_PLAYLIST), renditions,
|
return new TranscodeResult(joinKey(request.objectKeyPrefix(), MASTER_PLAYLIST), renditions,
|
||||||
poster.objectKey(), durationSec);
|
poster.objectKey(), durationSec, thumbnail);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ProducedRendition transcodeRung(TranscodeRequest request, LadderRung rung) {
|
private ProducedRendition transcodeRung(TranscodeRequest request, LadderRung rung) {
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.media.service.transcode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 WebVTT 缩略图索引;每个 cue 指向雪碧图中的 xywh 区域。
|
||||||
|
*/
|
||||||
|
public final class ThumbnailVttBuilder {
|
||||||
|
|
||||||
|
private static final String HEADER = "WEBVTT\n\n";
|
||||||
|
private static final int SECONDS_PER_MINUTE = 60;
|
||||||
|
private static final int MINUTES_PER_HOUR = 60;
|
||||||
|
|
||||||
|
private ThumbnailVttBuilder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 雪碧图布局与尺寸。 */
|
||||||
|
public record Layout(int durationSec, int intervalSec, int columns, int rows,
|
||||||
|
int thumbWidth, int thumbHeight) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按固定 tile 布局生成所有 cue。 */
|
||||||
|
public static String build(Layout layout) {
|
||||||
|
int frames = frameCount(layout.durationSec(), layout.intervalSec());
|
||||||
|
int framesPerSprite = layout.columns() * layout.rows();
|
||||||
|
int duration = layout.durationSec() > 0
|
||||||
|
? layout.durationSec() : layout.intervalSec();
|
||||||
|
StringBuilder body = new StringBuilder(HEADER);
|
||||||
|
for (int index = 0; index < frames; index++) {
|
||||||
|
appendCue(body, layout, duration, index, framesPerSprite);
|
||||||
|
}
|
||||||
|
return body.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 采样帧数,未知时长也保留一帧。 */
|
||||||
|
public static int frameCount(int durationSec, int intervalSec) {
|
||||||
|
if (durationSec <= 0 || intervalSec <= 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Math.max(1, (durationSec + intervalSec - 1) / intervalSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据帧数和列数计算 tile 行数。 */
|
||||||
|
public static int rows(int frameCount, int columns) {
|
||||||
|
if (frameCount <= 0 || columns <= 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
int required = Math.max(1, (frameCount + columns - 1) / columns);
|
||||||
|
return Math.min(columns, required);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendCue(StringBuilder body, Layout layout, int duration,
|
||||||
|
int index, int framesPerSprite) {
|
||||||
|
int sheet = index / framesPerSprite + 1;
|
||||||
|
int frameInSheet = index % framesPerSprite;
|
||||||
|
int column = frameInSheet % layout.columns();
|
||||||
|
int row = frameInSheet / layout.columns();
|
||||||
|
int start = index * layout.intervalSec();
|
||||||
|
int end = Math.min(start + layout.intervalSec(), duration);
|
||||||
|
body.append(formatTimestamp(start)).append(" --> ")
|
||||||
|
.append(formatTimestamp(end)).append('\n')
|
||||||
|
.append(String.format("sprite_%03d.jpg#xywh=%d,%d,%d,%d",
|
||||||
|
sheet, column * layout.thumbWidth(), row * layout.thumbHeight(),
|
||||||
|
layout.thumbWidth(), layout.thumbHeight()))
|
||||||
|
.append("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatTimestamp(int totalSeconds) {
|
||||||
|
int hours = totalSeconds / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR);
|
||||||
|
int minutes = totalSeconds / SECONDS_PER_MINUTE % MINUTES_PER_HOUR;
|
||||||
|
int seconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||||
|
return String.format("%02d:%02d:%02d.%03d", hours, minutes, seconds, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-2
@@ -29,7 +29,24 @@ public interface TranscodeRunner {
|
|||||||
*/
|
*/
|
||||||
record TranscodeRequest(Long taskId, Path sourceFile, Path outputDir, String objectKeyPrefix,
|
record TranscodeRequest(Long taskId, Path sourceFile, Path outputDir, String objectKeyPrefix,
|
||||||
List<LadderRung> ladder, String ffmpegPath, String ffprobePath,
|
List<LadderRung> ladder, String ffmpegPath, String ffprobePath,
|
||||||
int segmentSeconds, int posterSecond, int timeoutSeconds) {
|
int segmentSeconds, int posterSecond, int timeoutSeconds,
|
||||||
|
SpriteOptions sprite) {
|
||||||
|
|
||||||
|
/** 兼容未启用缩略图的既有调用。 */
|
||||||
|
public TranscodeRequest(Long taskId, Path sourceFile, Path outputDir, String objectKeyPrefix,
|
||||||
|
List<LadderRung> ladder, String ffmpegPath, String ffprobePath,
|
||||||
|
int segmentSeconds, int posterSecond, int timeoutSeconds) {
|
||||||
|
this(taskId, sourceFile, outputDir, objectKeyPrefix, ladder, ffmpegPath, ffprobePath,
|
||||||
|
segmentSeconds, posterSecond, timeoutSeconds, SpriteOptions.disabled());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 雪碧图采样参数。 */
|
||||||
|
record SpriteOptions(boolean enabled, int intervalSeconds, int columns, int thumbWidth) {
|
||||||
|
|
||||||
|
public static SpriteOptions disabled() {
|
||||||
|
return new SpriteOptions(false, 1, 10, 160);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一档产物。 */
|
/** 一档产物。 */
|
||||||
@@ -37,14 +54,29 @@ public interface TranscodeRunner {
|
|||||||
int audioBitrateKbps, String objectKey, long sizeBytes, int segmentCount) {
|
int audioBitrateKbps, String objectKey, long sizeBytes, int segmentCount) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 雪碧图与 VTT 产物;失败也返回本记录,保证主转码可继续成功。 */
|
||||||
|
record ProducedThumbnail(String vttObjectKey, String spritePrefix, int intervalSec,
|
||||||
|
int columns, int rows, int thumbWidth, int thumbHeight,
|
||||||
|
int frameCount, int durationSec, long sizeBytes,
|
||||||
|
String status, String errorMessage) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转码产物。
|
* 转码产物。
|
||||||
*
|
*
|
||||||
* @param masterObjectKey HLS 主播放列表的 objectKey(客户端可播入口)
|
* @param masterObjectKey HLS 主播放列表的 objectKey(客户端可播入口)
|
||||||
* @param posterObjectKey 封面截图 objectKey
|
* @param posterObjectKey 封面截图 objectKey
|
||||||
* @param durationSec 视频时长(ffprobe 读出,0 表示读不到)
|
* @param durationSec 视频时长(ffprobe 读出,0 表示读不到)
|
||||||
|
* @param thumbnail 缩略图产物;未启用时为 null
|
||||||
*/
|
*/
|
||||||
record TranscodeResult(String masterObjectKey, List<ProducedRendition> renditions,
|
record TranscodeResult(String masterObjectKey, List<ProducedRendition> renditions,
|
||||||
String posterObjectKey, int durationSec) {
|
String posterObjectKey, int durationSec,
|
||||||
|
ProducedThumbnail thumbnail) {
|
||||||
|
|
||||||
|
/** 兼容既有测试与未启用缩略图的调用。 */
|
||||||
|
public TranscodeResult(String masterObjectKey, List<ProducedRendition> renditions,
|
||||||
|
String posterObjectKey, int durationSec) {
|
||||||
|
this(masterObjectKey, renditions, posterObjectKey, durationSec, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+68
-8
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
package com.fruitshorts.media.service.transcode;
|
package com.fruitshorts.media.service.transcode;
|
||||||
|
|
||||||
import com.fruitshorts.media.config.TranscodeProperties;
|
import com.fruitshorts.media.config.MediaTranscodeProfile;
|
||||||
|
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||||
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||||
import com.fruitshorts.media.service.MediaService;
|
import com.fruitshorts.media.service.MediaService;
|
||||||
import com.fruitshorts.media.service.MediaStorageService;
|
import com.fruitshorts.media.service.MediaStorageService;
|
||||||
|
import com.fruitshorts.media.service.MediaTranscodeProfileService;
|
||||||
|
import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort;
|
||||||
import com.fruitshorts.media.service.TranscodeService;
|
import com.fruitshorts.media.service.TranscodeService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -39,7 +42,8 @@ public class TranscodeWorker {
|
|||||||
private final MediaService mediaService;
|
private final MediaService mediaService;
|
||||||
private final MediaStorageService storageService;
|
private final MediaStorageService storageService;
|
||||||
private final TranscodeRunner transcodeRunner;
|
private final TranscodeRunner transcodeRunner;
|
||||||
private final TranscodeProperties properties;
|
private final MediaTranscodeProfileService profileService;
|
||||||
|
private final EpisodeTranscodeCompletionPort episodeCompletionPort;
|
||||||
|
|
||||||
/** 定时轮询:队列空就按 poll-delay 再来一轮;上一轮没跑完不会被下一轮叠加(fixedDelay)。 */
|
/** 定时轮询:队列空就按 poll-delay 再来一轮;上一轮没跑完不会被下一轮叠加(fixedDelay)。 */
|
||||||
@Scheduled(fixedDelayString = "${fruitshorts.media.transcode.poll-delay-ms:5000}")
|
@Scheduled(fixedDelayString = "${fruitshorts.media.transcode.poll-delay-ms:5000}")
|
||||||
@@ -71,23 +75,33 @@ public class TranscodeWorker {
|
|||||||
long startedAt = System.currentTimeMillis();
|
long startedAt = System.currentTimeMillis();
|
||||||
String objectKeyPrefix = outputPrefix(task);
|
String objectKeyPrefix = outputPrefix(task);
|
||||||
try {
|
try {
|
||||||
|
MediaTranscodeProfile profile = profileService.currentProfile();
|
||||||
Path sourceFile = storageService.fileFor(storageService.objectKeyOf(task.getSourceUrl()));
|
Path sourceFile = storageService.fileFor(storageService.objectKeyOf(task.getSourceUrl()));
|
||||||
Path outputDir = storageService.fileFor(objectKeyPrefix);
|
Path outputDir = storageService.fileFor(objectKeyPrefix);
|
||||||
TranscodeRunner.TranscodeResult result = transcodeRunner.run(new TranscodeRunner.TranscodeRequest(
|
TranscodeRunner.TranscodeResult result = transcodeRunner.run(new TranscodeRunner.TranscodeRequest(
|
||||||
task.getId(), sourceFile, outputDir, objectKeyPrefix, ladder(), properties.getFfmpegPath(),
|
task.getId(), sourceFile, outputDir, objectKeyPrefix, ladder(profile),
|
||||||
properties.getFfprobePath(), properties.getSegmentSeconds(), properties.getPosterSecond(),
|
profileService.ffmpegPath(), profileService.ffprobePath(),
|
||||||
properties.getTimeoutSeconds()));
|
profile.getSegmentSeconds(), profileService.posterSecond(),
|
||||||
|
profileService.timeoutSeconds(), spriteOptions(profile)));
|
||||||
|
|
||||||
transcodeService.succeed(task.getId(), toRenditions(result));
|
transcodeService.succeed(task.getId(), toRenditions(result),
|
||||||
|
toThumbnail(task, result.thumbnail()));
|
||||||
if (task.getMediaAssetId() != null && task.getMediaAssetId() > 0) {
|
if (task.getMediaAssetId() != null && task.getMediaAssetId() > 0) {
|
||||||
mediaService.markReady(task.getMediaAssetId(),
|
mediaService.markReady(task.getMediaAssetId(),
|
||||||
storageService.toPublicUrl(result.masterObjectKey()),
|
storageService.toPublicUrl(result.masterObjectKey()),
|
||||||
storageService.toPublicUrl(result.posterObjectKey()),
|
storageService.toPublicUrl(result.posterObjectKey()),
|
||||||
result.durationSec());
|
result.durationSec());
|
||||||
}
|
}
|
||||||
|
if (task.getEpisodeId() != null && task.getEpisodeId() > 0) {
|
||||||
|
episodeCompletionPort.markEpisodeReady(
|
||||||
|
task.getEpisodeId(),
|
||||||
|
storageService.toPublicUrl(result.masterObjectKey()),
|
||||||
|
result.durationSec());
|
||||||
|
}
|
||||||
log.info("转码成功 task={} asset={} 档数={} 耗时={}ms 产物前缀={}",
|
log.info("转码成功 task={} asset={} 档数={} 耗时={}ms 产物前缀={}",
|
||||||
task.getId(), task.getMediaAssetId(), result.renditions().size(),
|
task.getId(), task.getMediaAssetId(), result.renditions().size(),
|
||||||
System.currentTimeMillis() - startedAt, objectKeyPrefix);
|
System.currentTimeMillis() - startedAt, objectKeyPrefix);
|
||||||
|
logThumbnailResult(result.thumbnail());
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
String reason = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
|
String reason = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
|
||||||
int attempt = (task.getRetryCount() == null ? 0 : task.getRetryCount()) + 1;
|
int attempt = (task.getRetryCount() == null ? 0 : task.getRetryCount()) + 1;
|
||||||
@@ -107,15 +121,61 @@ public class TranscodeWorker {
|
|||||||
return "transcoded/" + task.getId();
|
return "transcoded/" + task.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<TranscodeRunner.LadderRung> ladder() {
|
private List<TranscodeRunner.LadderRung> ladder(MediaTranscodeProfile profile) {
|
||||||
List<TranscodeRunner.LadderRung> ladder = new ArrayList<>();
|
List<TranscodeRunner.LadderRung> ladder = new ArrayList<>();
|
||||||
for (TranscodeProperties.Rung rung : properties.getLadder()) {
|
for (MediaTranscodeProfile.Rung rung : profile.getLadder()) {
|
||||||
|
if (!rung.isEnabled()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ladder.add(new TranscodeRunner.LadderRung(rung.getQuality(), rung.getWidth(), rung.getHeight(),
|
ladder.add(new TranscodeRunner.LadderRung(rung.getQuality(), rung.getWidth(), rung.getHeight(),
|
||||||
rung.getVideoBitrateKbps(), rung.getAudioBitrateKbps()));
|
rung.getVideoBitrateKbps(), rung.getAudioBitrateKbps()));
|
||||||
}
|
}
|
||||||
return ladder;
|
return ladder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TranscodeRunner.SpriteOptions spriteOptions(MediaTranscodeProfile profile) {
|
||||||
|
MediaTranscodeProfile.Sprite sprite = profile.getSprite();
|
||||||
|
return new TranscodeRunner.SpriteOptions(
|
||||||
|
sprite.isEnabled(), sprite.getIntervalSeconds(),
|
||||||
|
sprite.getColumns(), sprite.getThumbWidth());
|
||||||
|
}
|
||||||
|
|
||||||
|
private MediaThumbnail toThumbnail(
|
||||||
|
TranscodeTask task, TranscodeRunner.ProducedThumbnail produced) {
|
||||||
|
if (produced == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MediaThumbnail thumbnail = new MediaThumbnail();
|
||||||
|
thumbnail.setEpisodeId(task.getEpisodeId());
|
||||||
|
thumbnail.setDramaId(task.getDramaId());
|
||||||
|
thumbnail.setVttObjectKey(produced.vttObjectKey());
|
||||||
|
thumbnail.setSpritePrefix(produced.spritePrefix());
|
||||||
|
thumbnail.setIntervalSec(produced.intervalSec());
|
||||||
|
thumbnail.setColumns(produced.columns());
|
||||||
|
thumbnail.setRows(produced.rows());
|
||||||
|
thumbnail.setThumbWidth(produced.thumbWidth());
|
||||||
|
thumbnail.setThumbHeight(produced.thumbHeight());
|
||||||
|
thumbnail.setFrameCount(produced.frameCount());
|
||||||
|
thumbnail.setDurationSec(produced.durationSec());
|
||||||
|
thumbnail.setSizeBytes(produced.sizeBytes());
|
||||||
|
thumbnail.setStatus(produced.status());
|
||||||
|
thumbnail.setErrorMsg(produced.errorMessage());
|
||||||
|
return thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logThumbnailResult(TranscodeRunner.ProducedThumbnail thumbnail) {
|
||||||
|
if (thumbnail == null) {
|
||||||
|
log.info("缩略图未启用");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (MediaThumbnail.STATUS_FAILED.equals(thumbnail.status())) {
|
||||||
|
log.warn("主转码成功但缩略图失败:{}", thumbnail.errorMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.info("缩略图完成:{} 帧,{}x{}", thumbnail.frameCount(),
|
||||||
|
thumbnail.columns(), thumbnail.rows());
|
||||||
|
}
|
||||||
|
|
||||||
private List<com.fruitshorts.media.dal.entity.MediaRendition> toRenditions(
|
private List<com.fruitshorts.media.dal.entity.MediaRendition> toRenditions(
|
||||||
TranscodeRunner.TranscodeResult result) {
|
TranscodeRunner.TranscodeResult result) {
|
||||||
List<com.fruitshorts.media.dal.entity.MediaRendition> renditions = new ArrayList<>();
|
List<com.fruitshorts.media.dal.entity.MediaRendition> renditions = new ArrayList<>();
|
||||||
|
|||||||
+32
-16
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
package com.fruitshorts.playback.service;
|
package com.fruitshorts.playback.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
import com.fruitshorts.content.dal.entity.Drama;
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
|
import com.fruitshorts.content.enums.ContentStatus;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
import com.fruitshorts.playback.dal.entity.UserFavorite;
|
import com.fruitshorts.playback.dal.entity.UserFavorite;
|
||||||
import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper;
|
import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper;
|
||||||
@@ -13,13 +17,17 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/** 收藏:切换、列表、批量删除;收藏数在同一事务内同步维护。 */
|
/** 收藏:切换、列表、批量删除;收藏数在同一事务内同步维护。 */
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class FavoriteService {
|
public class FavoriteService {
|
||||||
|
|
||||||
|
private static final int MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
private final UserFavoriteMapper favoriteMapper;
|
private final UserFavoriteMapper favoriteMapper;
|
||||||
private final DramaService dramaService;
|
private final DramaService dramaService;
|
||||||
|
|
||||||
@@ -55,30 +63,38 @@ public class FavoriteService {
|
|||||||
return count != null && count > 0;
|
return count != null && count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Long> dramaIds(Long userId) {
|
/** 分页查询收藏短剧,保持收藏时间与 dramaId 稳定排序。 */
|
||||||
return favoriteMapper.selectList(Wrappers.<UserFavorite>lambdaQuery()
|
public PageResult<Drama> page(Long userId, int pageIndex, int pageSize) {
|
||||||
|
int safeIndex = Math.max(pageIndex, 1);
|
||||||
|
int safeSize = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
||||||
|
Page<UserFavorite> request = new Page<>(safeIndex, safeSize);
|
||||||
|
String publishedDramaSql = "SELECT id FROM drama WHERE deleted = 0 AND status = '"
|
||||||
|
+ ContentStatus.PUBLISHED.getCode() + "'";
|
||||||
|
IPage<UserFavorite> result = favoriteMapper.selectPage(request,
|
||||||
|
Wrappers.<UserFavorite>lambdaQuery()
|
||||||
.eq(UserFavorite::getUserId, userId)
|
.eq(UserFavorite::getUserId, userId)
|
||||||
.orderByDesc(UserFavorite::getCreatedAt))
|
.inSql(UserFavorite::getDramaId, publishedDramaSql)
|
||||||
.stream().map(UserFavorite::getDramaId).toList();
|
.orderByDesc(UserFavorite::getCreatedAt)
|
||||||
}
|
.orderByDesc(UserFavorite::getDramaId));
|
||||||
|
List<Long> ids = result.getRecords().stream()
|
||||||
public List<Drama> listDramas(Long userId) {
|
.map(UserFavorite::getDramaId)
|
||||||
List<Long> ids = dramaIds(userId);
|
.toList();
|
||||||
if (ids.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
return List.of();
|
return new PageResult<>(result.getTotal(), List.of());
|
||||||
}
|
}
|
||||||
List<Drama> dramas = dramaService.listPublishedByIds(ids);
|
List<Drama> dramas = dramaService.listPublishedByIds(ids);
|
||||||
// 按收藏时间排序,已被下架的剧自然被过滤掉
|
Map<Long, Drama> dramaById = new HashMap<>();
|
||||||
|
for (Drama drama : dramas) {
|
||||||
|
dramaById.put(drama.getId(), drama);
|
||||||
|
}
|
||||||
List<Drama> ordered = new ArrayList<>(dramas.size());
|
List<Drama> ordered = new ArrayList<>(dramas.size());
|
||||||
for (Long id : ids) {
|
for (Long id : ids) {
|
||||||
for (Drama drama : dramas) {
|
Drama drama = dramaById.get(id);
|
||||||
if (drama.getId().equals(id)) {
|
if (drama != null) {
|
||||||
ordered.add(drama);
|
ordered.add(drama);
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ordered;
|
return new PageResult<>(result.getTotal(), ordered);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
|||||||
+3
@@ -8,6 +8,7 @@ import com.fruitshorts.content.service.DramaService;
|
|||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.media.dal.entity.MediaRendition;
|
import com.fruitshorts.media.dal.entity.MediaRendition;
|
||||||
import com.fruitshorts.media.service.PlaybackSigner;
|
import com.fruitshorts.media.service.PlaybackSigner;
|
||||||
|
import com.fruitshorts.media.service.ThumbnailUrlResolver;
|
||||||
import com.fruitshorts.media.service.TranscodeService;
|
import com.fruitshorts.media.service.TranscodeService;
|
||||||
import com.fruitshorts.playback.dal.entity.PlaybackTicket;
|
import com.fruitshorts.playback.dal.entity.PlaybackTicket;
|
||||||
import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper;
|
import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper;
|
||||||
@@ -46,6 +47,7 @@ public class PlaybackService {
|
|||||||
private final PlaybackSigner signer;
|
private final PlaybackSigner signer;
|
||||||
private final EntitlementPort entitlementPort;
|
private final EntitlementPort entitlementPort;
|
||||||
private final TranscodeService transcodeService;
|
private final TranscodeService transcodeService;
|
||||||
|
private final ThumbnailUrlResolver thumbnailUrlResolver;
|
||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public PlayRespVO play(Long userId, Long episodeId, String deviceId, String clientIp) {
|
public PlayRespVO play(Long userId, Long episodeId, String deviceId, String clientIp) {
|
||||||
@@ -65,6 +67,7 @@ public class PlaybackService {
|
|||||||
vo.setUnlockCoin(episode.getUnlockCoin());
|
vo.setUnlockCoin(episode.getUnlockCoin());
|
||||||
vo.setPreviewSeconds(episode.getPreviewSeconds());
|
vo.setPreviewSeconds(episode.getPreviewSeconds());
|
||||||
vo.setQualities(qualitiesOf(episode.getId()));
|
vo.setQualities(qualitiesOf(episode.getId()));
|
||||||
|
vo.setThumbnailsUrl(thumbnailUrlResolver.resolve(episode.getId()));
|
||||||
|
|
||||||
if (!StringUtils.hasText(source)) {
|
if (!StringUtils.hasText(source)) {
|
||||||
// 还没转码或没上传片源:明确告诉客户端"没有片源",而不是给一个播不了的地址
|
// 还没转码或没上传片源:明确告诉客户端"没有片源",而不是给一个播不了的地址
|
||||||
|
|||||||
+61
-12
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
package com.fruitshorts.playback.service;
|
package com.fruitshorts.playback.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.fruitshorts.common.pojo.PageResult;
|
||||||
import com.fruitshorts.content.dal.entity.Drama;
|
import com.fruitshorts.content.dal.entity.Drama;
|
||||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
import com.fruitshorts.content.service.DramaService;
|
import com.fruitshorts.content.service.DramaService;
|
||||||
import com.fruitshorts.content.service.EpisodeService;
|
import com.fruitshorts.content.service.EpisodeService;
|
||||||
import com.fruitshorts.playback.dal.entity.WatchProgress;
|
|
||||||
import com.fruitshorts.playback.dal.entity.WatchHistory;
|
import com.fruitshorts.playback.dal.entity.WatchHistory;
|
||||||
|
import com.fruitshorts.playback.dal.entity.WatchProgress;
|
||||||
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
|
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
|
||||||
import com.fruitshorts.playback.dal.mapper.WatchProgressMapper;
|
import com.fruitshorts.playback.dal.mapper.WatchProgressMapper;
|
||||||
import com.fruitshorts.playback.vo.ProgressRespVO;
|
import com.fruitshorts.playback.vo.ProgressRespVO;
|
||||||
@@ -19,7 +22,10 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 观看进度。
|
* 观看进度。
|
||||||
@@ -36,6 +42,7 @@ import java.util.List;
|
|||||||
public class WatchProgressService {
|
public class WatchProgressService {
|
||||||
|
|
||||||
private static final long FINISH_THRESHOLD_MS = 1000L;
|
private static final long FINISH_THRESHOLD_MS = 1000L;
|
||||||
|
private static final int MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
private final WatchProgressMapper progressMapper;
|
private final WatchProgressMapper progressMapper;
|
||||||
private final WatchHistoryMapper historyMapper;
|
private final WatchHistoryMapper historyMapper;
|
||||||
@@ -108,17 +115,17 @@ public class WatchProgressService {
|
|||||||
return progress == null ? null : toResp(progress);
|
return progress == null ? null : toResp(progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 最近观看列表("我的-观看记录")。 */
|
/** 分页查询最近观看,按更新时间倒序并以 id 兜底保证稳定。 */
|
||||||
public List<ProgressRespVO> recent(Long userId, int limit) {
|
public PageResult<ProgressRespVO> recentPage(Long userId, int pageIndex, int pageSize) {
|
||||||
List<WatchProgress> list = progressMapper.selectList(Wrappers.<WatchProgress>lambdaQuery()
|
int safeIndex = Math.max(pageIndex, 1);
|
||||||
.eq(WatchProgress::getUserId, userId)
|
int safeSize = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
|
||||||
.orderByDesc(WatchProgress::getUpdatedAt)
|
Page<WatchProgress> request = new Page<>(safeIndex, safeSize);
|
||||||
.last("limit " + (limit <= 0 || limit > 100 ? 20 : limit)));
|
IPage<WatchProgress> result = progressMapper.selectPage(request,
|
||||||
List<ProgressRespVO> result = new ArrayList<>(list.size());
|
Wrappers.<WatchProgress>lambdaQuery()
|
||||||
for (WatchProgress item : list) {
|
.eq(WatchProgress::getUserId, userId)
|
||||||
result.add(toResp(item));
|
.orderByDesc(WatchProgress::getUpdatedAt)
|
||||||
}
|
.orderByDesc(WatchProgress::getId));
|
||||||
return result;
|
return new PageResult<>(result.getTotal(), enrichDisplay(result.getRecords()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
@@ -146,6 +153,48 @@ public class WatchProgressService {
|
|||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<ProgressRespVO> enrichDisplay(List<WatchProgress> records) {
|
||||||
|
if (records.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<Long> dramaIds = records.stream()
|
||||||
|
.map(WatchProgress::getDramaId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
.toList();
|
||||||
|
List<Long> episodeIds = records.stream()
|
||||||
|
.map(WatchProgress::getEpisodeId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
.toList();
|
||||||
|
Map<Long, Drama> dramaById = new HashMap<>();
|
||||||
|
for (Drama drama : dramaService.listByIds(dramaIds)) {
|
||||||
|
dramaById.put(drama.getId(), drama);
|
||||||
|
}
|
||||||
|
Map<Long, DramaEpisode> episodeById = new HashMap<>();
|
||||||
|
for (DramaEpisode episode : episodeService.listByIds(episodeIds)) {
|
||||||
|
episodeById.put(episode.getId(), episode);
|
||||||
|
}
|
||||||
|
List<ProgressRespVO> result = new ArrayList<>(records.size());
|
||||||
|
for (WatchProgress progress : records) {
|
||||||
|
ProgressRespVO vo = toResp(progress);
|
||||||
|
Drama drama = dramaById.get(progress.getDramaId());
|
||||||
|
if (drama != null) {
|
||||||
|
vo.setDramaTitle(drama.getTitle());
|
||||||
|
vo.setDramaCover(drama.getCoverUrl());
|
||||||
|
vo.setTotalEpisodes(drama.getTotalEpisodes());
|
||||||
|
}
|
||||||
|
DramaEpisode episode = episodeById.get(progress.getEpisodeId());
|
||||||
|
if (episode != null) {
|
||||||
|
vo.setEpisodeNumber(episode.getEpNo());
|
||||||
|
}
|
||||||
|
result.add(vo);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** 观看流水明细(客服视图与后续行为分析用),只读。 */
|
/** 观看流水明细(客服视图与后续行为分析用),只读。 */
|
||||||
public List<WatchHistory> history(Long userId, int limit) {
|
public List<WatchHistory> history(Long userId, int limit) {
|
||||||
return historyMapper.selectList(Wrappers.<WatchHistory>lambdaQuery()
|
return historyMapper.selectList(Wrappers.<WatchHistory>lambdaQuery()
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public class PlayRespVO {
|
|||||||
private String playUrl;
|
private String playUrl;
|
||||||
/** 可用的清晰度档位 */
|
/** 可用的清晰度档位 */
|
||||||
private List<String> qualities;
|
private List<String> qualities;
|
||||||
|
/** 雪碧图 WebVTT 时间轴地址;没有产物时为 null */
|
||||||
|
private String thumbnailsUrl;
|
||||||
/** 需要多少金币解锁 */
|
/** 需要多少金币解锁 */
|
||||||
private Integer unlockCoin;
|
private Integer unlockCoin;
|
||||||
/** 试看秒数,0 表示不限制 */
|
/** 试看秒数,0 表示不限制 */
|
||||||
|
|||||||
+8
@@ -12,6 +12,14 @@ public class ProgressRespVO {
|
|||||||
|
|
||||||
private Long dramaId;
|
private Long dramaId;
|
||||||
private Long episodeId;
|
private Long episodeId;
|
||||||
|
/** 短剧标题,仅列表类接口批量装配。 */
|
||||||
|
private String dramaTitle;
|
||||||
|
/** 短剧封面,仅列表类接口批量装配。 */
|
||||||
|
private String dramaCover;
|
||||||
|
/** 当前续看集号。 */
|
||||||
|
private Integer episodeNumber;
|
||||||
|
/** 短剧总集数;短剧不存在时为空。 */
|
||||||
|
private Integer totalEpisodes;
|
||||||
private Long positionMs;
|
private Long positionMs;
|
||||||
private Long durationMs;
|
private Long durationMs;
|
||||||
private Boolean finished;
|
private Boolean finished;
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
<groupId>com.fruitshorts</groupId>
|
<groupId>com.fruitshorts</groupId>
|
||||||
<artifactId>fruitshorts-module-media</artifactId>
|
<artifactId>fruitshorts-module-media</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fruitshorts</groupId>
|
||||||
|
<artifactId>fruitshorts-module-content</artifactId>
|
||||||
|
</dependency>
|
||||||
<!-- 单测用(worker 的编排逻辑不依赖真实 ffmpeg) -->
|
<!-- 单测用(worker 的编排逻辑不依赖真实 ffmpeg) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.worker;
|
||||||
|
|
||||||
|
import com.fruitshorts.media.dal.entity.MediaAsset;
|
||||||
|
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
|
||||||
|
import com.fruitshorts.media.service.EpisodeTranscodeBindingService;
|
||||||
|
import com.fruitshorts.media.service.MediaStorageService;
|
||||||
|
import com.fruitshorts.media.service.TranscodeService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频 URL -> media_asset -> transcode_task 的分集绑定。
|
||||||
|
*/
|
||||||
|
class EpisodeTranscodeBindingServiceTest {
|
||||||
|
|
||||||
|
private MediaAssetMapper mediaAssetMapper;
|
||||||
|
private MediaStorageService storageService;
|
||||||
|
private TranscodeService transcodeService;
|
||||||
|
private EpisodeTranscodeBindingService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mediaAssetMapper = mock(MediaAssetMapper.class);
|
||||||
|
storageService = mock(MediaStorageService.class);
|
||||||
|
transcodeService = mock(TranscodeService.class);
|
||||||
|
service = new EpisodeTranscodeBindingService(
|
||||||
|
mediaAssetMapper, storageService, transcodeService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_find_asset_by_object_key_and_bind_task_when_episode_saved() {
|
||||||
|
MediaAsset asset = new MediaAsset();
|
||||||
|
asset.setId(88L);
|
||||||
|
asset.setSourceUrl("/files/episode/202610/video.mp4");
|
||||||
|
given(storageService.objectKeyOf("/files/episode/202610/video.mp4?e=1&s=x"))
|
||||||
|
.willReturn("episode/202610/video.mp4");
|
||||||
|
given(mediaAssetMapper.selectOne(any())).willReturn(asset);
|
||||||
|
|
||||||
|
Long assetId = service.bind(
|
||||||
|
31L, 7L, "/files/episode/202610/video.mp4?e=1&s=x");
|
||||||
|
|
||||||
|
assertThat(assetId).isEqualTo(88L);
|
||||||
|
verify(transcodeService).bindToEpisode(
|
||||||
|
88L, 7L, 31L, "/files/episode/202610/video.mp4");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_return_null_when_video_url_has_no_uploaded_asset() {
|
||||||
|
given(storageService.objectKeyOf("https://external.test/video.mp4"))
|
||||||
|
.willReturn("https://external.test/video.mp4");
|
||||||
|
given(mediaAssetMapper.selectOne(any())).willReturn(null);
|
||||||
|
|
||||||
|
assertThat(service.bind(31L, 7L, "https://external.test/video.mp4"))
|
||||||
|
.isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-2
@@ -45,7 +45,8 @@ class FfmpegTranscodeRunnerSmokeTest {
|
|||||||
1L, sample, output, "transcoded/smoke",
|
1L, sample, output, "transcoded/smoke",
|
||||||
List.of(new TranscodeRunner.LadderRung("360p", 640, 360, 400, 64),
|
List.of(new TranscodeRunner.LadderRung("360p", 640, 360, 400, 64),
|
||||||
new TranscodeRunner.LadderRung("480p", 854, 480, 800, 64)),
|
new TranscodeRunner.LadderRung("480p", 854, 480, 800, 64)),
|
||||||
ffmpeg, ffprobe, 2, 1, 120));
|
ffmpeg, ffprobe, 2, 1, 120,
|
||||||
|
new TranscodeRunner.SpriteOptions(true, 1, 10, 160)));
|
||||||
|
|
||||||
assertThat(Files.exists(output.resolve("master.m3u8"))).isTrue();
|
assertThat(Files.exists(output.resolve("master.m3u8"))).isTrue();
|
||||||
assertThat(Files.exists(output.resolve("poster.jpg"))).isTrue();
|
assertThat(Files.exists(output.resolve("poster.jpg"))).isTrue();
|
||||||
@@ -59,6 +60,12 @@ class FfmpegTranscodeRunnerSmokeTest {
|
|||||||
assertThat(rendition.sizeBytes()).isGreaterThan(0L);
|
assertThat(rendition.sizeBytes()).isGreaterThan(0L);
|
||||||
}
|
}
|
||||||
assertThat(result.durationSec()).isBetween(1, 5);
|
assertThat(result.durationSec()).isBetween(1, 5);
|
||||||
|
assertThat(result.thumbnail()).isNotNull();
|
||||||
|
assertThat(result.thumbnail().status()).isEqualTo("READY");
|
||||||
|
assertThat(result.thumbnail().thumbWidth()).isEqualTo(160);
|
||||||
|
assertThat(result.thumbnail().thumbHeight()).isEqualTo(90);
|
||||||
|
assertThat(Files.exists(output.resolve("thumbnails/thumbnails.vtt"))).isTrue();
|
||||||
|
assertThat(Files.exists(output.resolve("thumbnails/sprite_001.jpg"))).isTrue();
|
||||||
assertThat(readLines(output.resolve("master.m3u8")))
|
assertThat(readLines(output.resolve("master.m3u8")))
|
||||||
.anyMatch(line -> line.contains("360p/index.m3u8"))
|
.anyMatch(line -> line.contains("360p/index.m3u8"))
|
||||||
.anyMatch(line -> line.contains("480p/index.m3u8"));
|
.anyMatch(line -> line.contains("480p/index.m3u8"));
|
||||||
@@ -71,7 +78,8 @@ class FfmpegTranscodeRunnerSmokeTest {
|
|||||||
Process process = new ProcessBuilder(ffmpeg, "-y",
|
Process process = new ProcessBuilder(ffmpeg, "-y",
|
||||||
"-f", "lavfi", "-i", "testsrc=size=640x360:rate=15",
|
"-f", "lavfi", "-i", "testsrc=size=640x360:rate=15",
|
||||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
|
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
|
||||||
"-shortest", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac",
|
"-shortest", "-t", "2",
|
||||||
|
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac",
|
||||||
sample.toString())
|
sample.toString())
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.start();
|
.start();
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.worker;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fruitshorts.media.config.MediaTranscodeProfile;
|
||||||
|
import com.fruitshorts.media.config.TranscodeProperties;
|
||||||
|
import com.fruitshorts.media.service.MediaTranscodeProfileService;
|
||||||
|
import com.fruitshorts.system.service.ConfigService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台转码配置的解析、缓存与非法回退。
|
||||||
|
*/
|
||||||
|
class MediaTranscodeProfileServiceTest {
|
||||||
|
|
||||||
|
private ConfigService configService;
|
||||||
|
private MediaTranscodeProfileService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
configService = mock(ConfigService.class);
|
||||||
|
service = new MediaTranscodeProfileService(
|
||||||
|
configService, new ObjectMapper(), new TranscodeProperties());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_read_valid_profile_and_cache_for_30_seconds() throws Exception {
|
||||||
|
MediaTranscodeProfile profile = new MediaTranscodeProfile();
|
||||||
|
profile.setSegmentSeconds(4);
|
||||||
|
profile.getLadder().get(0).setVideoBitrateKbps(500);
|
||||||
|
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
|
||||||
|
.willReturn(new ObjectMapper().writeValueAsString(profile));
|
||||||
|
|
||||||
|
MediaTranscodeProfile first = service.currentProfile();
|
||||||
|
MediaTranscodeProfile second = service.currentProfile();
|
||||||
|
|
||||||
|
assertThat(first.getSegmentSeconds()).isEqualTo(4);
|
||||||
|
assertThat(first.getLadder().get(0).getVideoBitrateKbps()).isEqualTo(500);
|
||||||
|
assertThat(second).isSameAs(first);
|
||||||
|
verify(configService, times(1))
|
||||||
|
.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_fallback_to_yml_default_when_profile_is_illegal() {
|
||||||
|
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
|
||||||
|
.willReturn("{\"segmentSeconds\":1,\"ladder\":[]}");
|
||||||
|
|
||||||
|
MediaTranscodeProfile profile = service.currentProfile();
|
||||||
|
|
||||||
|
assertThat(profile.getSegmentSeconds()).isEqualTo(6);
|
||||||
|
assertThat(profile.getLadder()).extracting(MediaTranscodeProfile.Rung::getQuality)
|
||||||
|
.containsExactly("360p", "480p", "720p", "1080p");
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.worker;
|
||||||
|
|
||||||
|
import com.fruitshorts.media.service.transcode.ThumbnailVttBuilder;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebVTT cue 时间与雪碧图行列坐标。
|
||||||
|
*/
|
||||||
|
class ThumbnailVttBuilderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_write_time_ranges_and_xywh_coordinates() {
|
||||||
|
String vtt = ThumbnailVttBuilder.build(
|
||||||
|
new ThumbnailVttBuilder.Layout(3, 1, 10, 1, 160, 90));
|
||||||
|
|
||||||
|
assertThat(vtt).startsWith("WEBVTT");
|
||||||
|
assertThat(vtt).contains("00:00:00.000 --> 00:00:01.000\n"
|
||||||
|
+ "sprite_001.jpg#xywh=0,0,160,90");
|
||||||
|
assertThat(vtt).contains("00:00:01.000 --> 00:00:02.000\n"
|
||||||
|
+ "sprite_001.jpg#xywh=160,0,160,90");
|
||||||
|
assertThat(vtt).contains("00:00:02.000 --> 00:00:03.000\n"
|
||||||
|
+ "sprite_001.jpg#xywh=320,0,160,90");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_switch_to_second_sprite_after_full_tile() {
|
||||||
|
String vtt = ThumbnailVttBuilder.build(
|
||||||
|
new ThumbnailVttBuilder.Layout(101, 1, 10, 10, 160, 90));
|
||||||
|
|
||||||
|
assertThat(vtt).contains("00:01:40.000 --> 00:01:41.000\n"
|
||||||
|
+ "sprite_002.jpg#xywh=0,0,160,90");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_cap_tile_rows_at_column_count() {
|
||||||
|
assertThat(ThumbnailVttBuilder.frameCount(21, 1)).isEqualTo(21);
|
||||||
|
assertThat(ThumbnailVttBuilder.rows(21, 10)).isEqualTo(3);
|
||||||
|
assertThat(ThumbnailVttBuilder.rows(121, 10)).isEqualTo(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
-3
@@ -3,18 +3,24 @@
|
|||||||
package com.fruitshorts.worker;
|
package com.fruitshorts.worker;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
import com.fruitshorts.common.exception.ServiceException;
|
import com.fruitshorts.common.exception.ServiceException;
|
||||||
import com.fruitshorts.media.config.MediaProperties;
|
import com.fruitshorts.media.config.MediaProperties;
|
||||||
|
import com.fruitshorts.media.dal.entity.MediaRendition;
|
||||||
|
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||||
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||||
import com.fruitshorts.media.dal.mapper.MediaRenditionMapper;
|
import com.fruitshorts.media.dal.mapper.MediaRenditionMapper;
|
||||||
|
import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper;
|
||||||
import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper;
|
import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper;
|
||||||
import com.fruitshorts.media.service.MediaStorageService;
|
import com.fruitshorts.media.service.MediaStorageService;
|
||||||
import com.fruitshorts.media.service.TranscodeService;
|
import com.fruitshorts.media.service.TranscodeService;
|
||||||
|
import com.fruitshorts.media.service.ThumbnailRecordService;
|
||||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
@@ -24,6 +30,7 @@ import static org.mockito.ArgumentMatchers.any;
|
|||||||
import static org.mockito.BDDMockito.given;
|
import static org.mockito.BDDMockito.given;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,12 +46,16 @@ class TranscodeClaimAndStorageTest {
|
|||||||
*/
|
*/
|
||||||
@BeforeAll
|
@BeforeAll
|
||||||
static void initTableInfoCache() {
|
static void initTableInfoCache() {
|
||||||
TableInfoHelper.initTableInfo(
|
MapperBuilderAssistant assistant =
|
||||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""), TranscodeTask.class);
|
new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TranscodeTask.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, MediaRendition.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, MediaThumbnail.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TranscodeTaskMapper transcodeTaskMapper;
|
private TranscodeTaskMapper transcodeTaskMapper;
|
||||||
private MediaRenditionMapper mediaRenditionMapper;
|
private MediaRenditionMapper mediaRenditionMapper;
|
||||||
|
private MediaThumbnailMapper mediaThumbnailMapper;
|
||||||
private TranscodeService transcodeService;
|
private TranscodeService transcodeService;
|
||||||
private MediaProperties mediaProperties;
|
private MediaProperties mediaProperties;
|
||||||
private MediaStorageService storageService;
|
private MediaStorageService storageService;
|
||||||
@@ -53,7 +64,12 @@ class TranscodeClaimAndStorageTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
transcodeTaskMapper = mock(TranscodeTaskMapper.class);
|
transcodeTaskMapper = mock(TranscodeTaskMapper.class);
|
||||||
mediaRenditionMapper = mock(MediaRenditionMapper.class);
|
mediaRenditionMapper = mock(MediaRenditionMapper.class);
|
||||||
transcodeService = new TranscodeService(transcodeTaskMapper, mediaRenditionMapper);
|
mediaThumbnailMapper = mock(MediaThumbnailMapper.class);
|
||||||
|
ThumbnailRecordService thumbnailRecordService =
|
||||||
|
new ThumbnailRecordService(mediaThumbnailMapper);
|
||||||
|
transcodeService = new TranscodeService(
|
||||||
|
transcodeTaskMapper, mediaRenditionMapper,
|
||||||
|
mediaThumbnailMapper, thumbnailRecordService);
|
||||||
mediaProperties = new MediaProperties();
|
mediaProperties = new MediaProperties();
|
||||||
mediaProperties.setRootDir("/tmp/fruitshorts-storage-test");
|
mediaProperties.setRootDir("/tmp/fruitshorts-storage-test");
|
||||||
mediaProperties.setPublicPrefix("/files");
|
mediaProperties.setPublicPrefix("/files");
|
||||||
@@ -80,6 +96,59 @@ class TranscodeClaimAndStorageTest {
|
|||||||
assertThat(claimed.getId()).isEqualTo(2L);
|
assertThat(claimed.getId()).isEqualTo(2L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_exclude_unbound_queued_task_from_claim_query() {
|
||||||
|
given(transcodeTaskMapper.selectOne(any())).willReturn(null);
|
||||||
|
|
||||||
|
assertThat(transcodeService.claimNext()).isNull();
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<Wrapper<TranscodeTask>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||||
|
verify(transcodeTaskMapper).selectOne(captor.capture());
|
||||||
|
assertThat(captor.getValue().getSqlSegment()).contains("episode_id >");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_reuse_and_backfill_existing_task_when_episode_is_bound() {
|
||||||
|
TranscodeTask existing = task(4L);
|
||||||
|
existing.setMediaAssetId(88L);
|
||||||
|
existing.setEpisodeId(0L);
|
||||||
|
existing.setDramaId(0L);
|
||||||
|
given(transcodeTaskMapper.selectOne(any())).willReturn(existing);
|
||||||
|
|
||||||
|
Long taskId = transcodeService.bindToEpisode(
|
||||||
|
88L, 7L, 31L, "/files/episode/202610/video.mp4");
|
||||||
|
|
||||||
|
assertThat(taskId).isEqualTo(4L);
|
||||||
|
assertThat(existing.getDramaId()).isEqualTo(7L);
|
||||||
|
assertThat(existing.getEpisodeId()).isEqualTo(31L);
|
||||||
|
verify(transcodeTaskMapper).update(any(), any());
|
||||||
|
verify(transcodeTaskMapper, never()).insert(any(TranscodeTask.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_keep_main_success_when_thumbnail_record_transaction_fails() {
|
||||||
|
TranscodeTask task = task(5L);
|
||||||
|
task.setDramaId(7L);
|
||||||
|
task.setEpisodeId(31L);
|
||||||
|
given(transcodeTaskMapper.selectById(5L)).willReturn(task);
|
||||||
|
ThumbnailRecordService failingRecordService = mock(ThumbnailRecordService.class);
|
||||||
|
doThrow(new IllegalStateException("media_thumbnail does not exist"))
|
||||||
|
.when(failingRecordService).upsert(any(), any());
|
||||||
|
TranscodeService guardedService = new TranscodeService(
|
||||||
|
transcodeTaskMapper, mediaRenditionMapper,
|
||||||
|
mediaThumbnailMapper, failingRecordService);
|
||||||
|
MediaThumbnail thumbnail = new MediaThumbnail();
|
||||||
|
thumbnail.setEpisodeId(31L);
|
||||||
|
thumbnail.setStatus(MediaThumbnail.STATUS_READY);
|
||||||
|
|
||||||
|
guardedService.succeed(5L, java.util.List.of(), thumbnail);
|
||||||
|
|
||||||
|
assertThat(task.getStatus()).isEqualTo(TranscodeService.STATUS_SUCCESS);
|
||||||
|
assertThat(task.getErrorMsg()).contains("缩略图登记失败");
|
||||||
|
verify(transcodeTaskMapper).updateById(task);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_return_null_when_queue_is_empty() {
|
void should_return_null_when_queue_is_empty() {
|
||||||
given(transcodeTaskMapper.selectOne(any())).willReturn(null);
|
given(transcodeTaskMapper.selectOne(any())).willReturn(null);
|
||||||
@@ -88,6 +157,24 @@ class TranscodeClaimAndStorageTest {
|
|||||||
verify(transcodeTaskMapper, never()).update(any(), any());
|
verify(transcodeTaskMapper, never()).update(any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_insert_thumbnail_when_main_transcode_succeeds() {
|
||||||
|
TranscodeTask task = task(3L);
|
||||||
|
task.setDramaId(7L);
|
||||||
|
task.setEpisodeId(11L);
|
||||||
|
given(transcodeTaskMapper.selectById(3L)).willReturn(task);
|
||||||
|
MediaThumbnail thumbnail = new MediaThumbnail();
|
||||||
|
thumbnail.setEpisodeId(11L);
|
||||||
|
thumbnail.setVttObjectKey("transcoded/3/thumbnails/thumbnails.vtt");
|
||||||
|
thumbnail.setStatus(MediaThumbnail.STATUS_READY);
|
||||||
|
|
||||||
|
transcodeService.succeed(3L, java.util.List.of(), thumbnail);
|
||||||
|
|
||||||
|
verify(mediaThumbnailMapper).insert(thumbnail);
|
||||||
|
assertThat(task.getStatus()).isEqualTo(TranscodeService.STATUS_SUCCESS);
|
||||||
|
verify(transcodeTaskMapper).updateById(task);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_reject_object_key_that_escapes_the_storage_root() {
|
void should_reject_object_key_that_escapes_the_storage_root() {
|
||||||
assertThatThrownBy(() -> storageService.fileFor("../../etc/passwd"))
|
assertThatThrownBy(() -> storageService.fileFor("../../etc/passwd"))
|
||||||
@@ -116,6 +203,7 @@ class TranscodeClaimAndStorageTest {
|
|||||||
task.setId(id);
|
task.setId(id);
|
||||||
task.setStatus(TranscodeService.STATUS_QUEUED);
|
task.setStatus(TranscodeService.STATUS_QUEUED);
|
||||||
task.setRetryCount(0);
|
task.setRetryCount(0);
|
||||||
|
task.setEpisodeId(11L);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+57
-4
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
package com.fruitshorts.worker;
|
package com.fruitshorts.worker;
|
||||||
|
|
||||||
import com.fruitshorts.media.config.TranscodeProperties;
|
import com.fruitshorts.media.config.MediaTranscodeProfile;
|
||||||
import com.fruitshorts.media.dal.entity.MediaRendition;
|
import com.fruitshorts.media.dal.entity.MediaRendition;
|
||||||
|
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||||
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||||
import com.fruitshorts.media.service.MediaService;
|
import com.fruitshorts.media.service.MediaService;
|
||||||
import com.fruitshorts.media.service.MediaStorageService;
|
import com.fruitshorts.media.service.MediaStorageService;
|
||||||
|
import com.fruitshorts.media.service.MediaTranscodeProfileService;
|
||||||
|
import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort;
|
||||||
import com.fruitshorts.media.service.TranscodeService;
|
import com.fruitshorts.media.service.TranscodeService;
|
||||||
import com.fruitshorts.media.service.transcode.TranscodeRunner;
|
import com.fruitshorts.media.service.transcode.TranscodeRunner;
|
||||||
import com.fruitshorts.media.service.transcode.TranscodeWorker;
|
import com.fruitshorts.media.service.transcode.TranscodeWorker;
|
||||||
@@ -41,6 +44,8 @@ class TranscodeWorkerTest {
|
|||||||
private MediaService mediaService;
|
private MediaService mediaService;
|
||||||
private MediaStorageService storageService;
|
private MediaStorageService storageService;
|
||||||
private StubRunner runner;
|
private StubRunner runner;
|
||||||
|
private MediaTranscodeProfileService profileService;
|
||||||
|
private EpisodeTranscodeCompletionPort episodeCompletionPort;
|
||||||
private TranscodeWorker worker;
|
private TranscodeWorker worker;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -48,9 +53,16 @@ class TranscodeWorkerTest {
|
|||||||
transcodeService = mock(TranscodeService.class);
|
transcodeService = mock(TranscodeService.class);
|
||||||
mediaService = mock(MediaService.class);
|
mediaService = mock(MediaService.class);
|
||||||
storageService = mock(MediaStorageService.class);
|
storageService = mock(MediaStorageService.class);
|
||||||
|
profileService = mock(MediaTranscodeProfileService.class);
|
||||||
|
episodeCompletionPort = mock(EpisodeTranscodeCompletionPort.class);
|
||||||
runner = new StubRunner();
|
runner = new StubRunner();
|
||||||
worker = new TranscodeWorker(transcodeService, mediaService, storageService, runner,
|
worker = new TranscodeWorker(transcodeService, mediaService, storageService, runner,
|
||||||
new TranscodeProperties());
|
profileService, episodeCompletionPort);
|
||||||
|
given(profileService.currentProfile()).willReturn(new MediaTranscodeProfile());
|
||||||
|
given(profileService.ffmpegPath()).willReturn("ffmpeg");
|
||||||
|
given(profileService.ffprobePath()).willReturn("ffprobe");
|
||||||
|
given(profileService.posterSecond()).willReturn(1);
|
||||||
|
given(profileService.timeoutSeconds()).willReturn(120);
|
||||||
given(storageService.objectKeyOf(anyString())).willReturn("episode/202610/1.mp4");
|
given(storageService.objectKeyOf(anyString())).willReturn("episode/202610/1.mp4");
|
||||||
given(storageService.fileFor(anyString())).willReturn(Paths.get("/tmp/fruitshorts-test/1.mp4"));
|
given(storageService.fileFor(anyString())).willReturn(Paths.get("/tmp/fruitshorts-test/1.mp4"));
|
||||||
given(storageService.toPublicUrl(anyString()))
|
given(storageService.toPublicUrl(anyString()))
|
||||||
@@ -65,13 +77,21 @@ class TranscodeWorkerTest {
|
|||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ArgumentCaptor<List<MediaRendition>> captor = ArgumentCaptor.forClass(List.class);
|
ArgumentCaptor<List<MediaRendition>> captor = ArgumentCaptor.forClass(List.class);
|
||||||
verify(transcodeService).succeed(eq(TASK_ID), captor.capture());
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<MediaThumbnail> thumbnailCaptor =
|
||||||
|
ArgumentCaptor.forClass(MediaThumbnail.class);
|
||||||
|
verify(transcodeService).succeed(eq(TASK_ID), captor.capture(),
|
||||||
|
thumbnailCaptor.capture());
|
||||||
assertThat(captor.getValue()).hasSize(2);
|
assertThat(captor.getValue()).hasSize(2);
|
||||||
// 对外地址一律经 MediaStorageService,worker 不自己拼磁盘路径
|
// 对外地址一律经 MediaStorageService,worker 不自己拼磁盘路径
|
||||||
assertThat(captor.getValue().get(0).getPlayUrl()).startsWith("/files/transcoded/88/");
|
assertThat(captor.getValue().get(0).getPlayUrl()).startsWith("/files/transcoded/88/");
|
||||||
assertThat(captor.getValue().get(0).getFormat()).isEqualTo("HLS");
|
assertThat(captor.getValue().get(0).getFormat()).isEqualTo("HLS");
|
||||||
|
assertThat(thumbnailCaptor.getValue().getStatus())
|
||||||
|
.isEqualTo(MediaThumbnail.STATUS_READY);
|
||||||
verify(mediaService).markReady(eq(ASSET_ID), eq("/files/transcoded/88/master.m3u8"),
|
verify(mediaService).markReady(eq(ASSET_ID), eq("/files/transcoded/88/master.m3u8"),
|
||||||
eq("/files/transcoded/88/poster.jpg"), eq(120));
|
eq("/files/transcoded/88/poster.jpg"), eq(120));
|
||||||
|
verify(episodeCompletionPort).markEpisodeReady(
|
||||||
|
12L, "/files/transcoded/88/master.m3u8", 120);
|
||||||
verify(transcodeService, never()).fail(any(), anyString());
|
verify(transcodeService, never()).fail(any(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +107,31 @@ class TranscodeWorkerTest {
|
|||||||
assertThat(runner.request.objectKeyPrefix()).isEqualTo("transcoded/88");
|
assertThat(runner.request.objectKeyPrefix()).isEqualTo("transcoded/88");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_keep_main_transcode_successful_when_thumbnail_generation_fails() {
|
||||||
|
given(transcodeService.claimNext()).willReturn(queuedTask());
|
||||||
|
runner.thumbnail = new TranscodeRunner.ProducedThumbnail(
|
||||||
|
"transcoded/88/thumbnails/thumbnails.vtt",
|
||||||
|
"transcoded/88/thumbnails/sprite_",
|
||||||
|
1, 10, 1, 160, 0, 120, 120, 0L,
|
||||||
|
MediaThumbnail.STATUS_FAILED, "tile 滤镜失败");
|
||||||
|
|
||||||
|
worker.processOne();
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<List<MediaRendition>> renditions =
|
||||||
|
ArgumentCaptor.forClass(List.class);
|
||||||
|
ArgumentCaptor<MediaThumbnail> thumbnail =
|
||||||
|
ArgumentCaptor.forClass(MediaThumbnail.class);
|
||||||
|
verify(transcodeService).succeed(eq(TASK_ID), renditions.capture(),
|
||||||
|
thumbnail.capture());
|
||||||
|
assertThat(thumbnail.getValue().getStatus())
|
||||||
|
.isEqualTo(MediaThumbnail.STATUS_FAILED);
|
||||||
|
assertThat(thumbnail.getValue().getErrorMsg()).contains("tile");
|
||||||
|
verify(mediaService).markReady(eq(ASSET_ID), anyString(), anyString(), eq(120));
|
||||||
|
verify(transcodeService, never()).fail(any(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_keep_task_queued_and_record_reason_when_transcode_fails_before_max_retry() {
|
void should_keep_task_queued_and_record_reason_when_transcode_fails_before_max_retry() {
|
||||||
TranscodeTask task = queuedTask();
|
TranscodeTask task = queuedTask();
|
||||||
@@ -144,6 +189,7 @@ class TranscodeWorkerTest {
|
|||||||
private static final class StubRunner implements TranscodeRunner {
|
private static final class StubRunner implements TranscodeRunner {
|
||||||
private TranscodeRequest request;
|
private TranscodeRequest request;
|
||||||
private RuntimeException failure;
|
private RuntimeException failure;
|
||||||
|
private ProducedThumbnail thumbnail;
|
||||||
private boolean called;
|
private boolean called;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -153,13 +199,20 @@ class TranscodeWorkerTest {
|
|||||||
if (failure != null) {
|
if (failure != null) {
|
||||||
throw failure;
|
throw failure;
|
||||||
}
|
}
|
||||||
|
ProducedThumbnail resultThumbnail = thumbnail == null
|
||||||
|
? new ProducedThumbnail(
|
||||||
|
request.objectKeyPrefix() + "/thumbnails/thumbnails.vtt",
|
||||||
|
request.objectKeyPrefix() + "/thumbnails/sprite_",
|
||||||
|
1, 10, 1, 160, 90, 120, 120, 2048L,
|
||||||
|
MediaThumbnail.STATUS_READY, "")
|
||||||
|
: thumbnail;
|
||||||
return new TranscodeResult(
|
return new TranscodeResult(
|
||||||
request.objectKeyPrefix() + "/master.m3u8",
|
request.objectKeyPrefix() + "/master.m3u8",
|
||||||
List.of(new ProducedRendition("360p", 640, 360, 400, 64,
|
List.of(new ProducedRendition("360p", 640, 360, 400, 64,
|
||||||
request.objectKeyPrefix() + "/360p/index.m3u8", 1024L, 4),
|
request.objectKeyPrefix() + "/360p/index.m3u8", 1024L, 4),
|
||||||
new ProducedRendition("480p", 854, 480, 800, 64,
|
new ProducedRendition("480p", 854, 480, 800, 64,
|
||||||
request.objectKeyPrefix() + "/480p/index.m3u8", 2048L, 4)),
|
request.objectKeyPrefix() + "/480p/index.m3u8", 2048L, 4)),
|
||||||
request.objectKeyPrefix() + "/poster.jpg", 120);
|
request.objectKeyPrefix() + "/poster.jpg", 120, resultThumbnail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,5 +338,17 @@ INSERT INTO sys_config (id, config_key, config_value, remark) VALUES
|
|||||||
(4, 'app.register.max-per-device-per-day', '0', '0=不限制;生产建议 3')
|
(4, 'app.register.max-per-device-per-day', '0', '0=不限制;生产建议 3')
|
||||||
ON CONFLICT (config_key) DO NOTHING;
|
ON CONFLICT (config_key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO sys_config (id, config_key, config_value, remark) VALUES
|
||||||
|
(2026102201,
|
||||||
|
'media.transcode.profile',
|
||||||
|
'{"enabled":true,"segmentSeconds":6,"ladder":[' ||
|
||||||
|
'{"quality":"360p","width":640,"height":360,"videoBitrateKbps":400,"audioBitrateKbps":64,"enabled":true},' ||
|
||||||
|
'{"quality":"480p","width":854,"height":480,"videoBitrateKbps":800,"audioBitrateKbps":64,"enabled":true},' ||
|
||||||
|
'{"quality":"720p","width":1280,"height":720,"videoBitrateKbps":1600,"audioBitrateKbps":96,"enabled":true},' ||
|
||||||
|
'{"quality":"1080p","width":1920,"height":1080,"videoBitrateKbps":3200,"audioBitrateKbps":128,"enabled":true}],' ||
|
||||||
|
'"sprite":{"enabled":true,"intervalSeconds":1,"columns":10,"thumbWidth":160}}',
|
||||||
|
'转码档位与缩略图轴配置')
|
||||||
|
ON CONFLICT (config_key) DO NOTHING;
|
||||||
|
|
||||||
-- K23:二级菜单统一只显示文字,历史图标值也一并清空。
|
-- K23:二级菜单统一只显示文字,历史图标值也一并清空。
|
||||||
UPDATE sys_menu SET icon = '' WHERE type = 'MENU';
|
UPDATE sys_menu SET icon = '' WHERE type = 'MENU';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Author: fruit.com.cn
|
||||||
|
-- 将已有零绑定转码任务按分集视频 URL 回填(幂等,仅处理未完成任务)
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE transcode_task t
|
||||||
|
SET drama_id = e.drama_id,
|
||||||
|
episode_id = e.id
|
||||||
|
FROM drama_episode e
|
||||||
|
WHERE t.deleted = 0
|
||||||
|
AND e.deleted = 0
|
||||||
|
AND t.status IN ('QUEUED', 'RUNNING')
|
||||||
|
AND t.episode_id = 0
|
||||||
|
AND e.drama_id > 0
|
||||||
|
AND split_part(e.video_url, '?', 1) = split_part(t.source_url, '?', 1);
|
||||||
|
|
||||||
|
UPDATE drama_episode e
|
||||||
|
SET video_asset_id = t.media_asset_id
|
||||||
|
FROM transcode_task t
|
||||||
|
WHERE t.deleted = 0
|
||||||
|
AND t.episode_id = e.id
|
||||||
|
AND t.media_asset_id > 0
|
||||||
|
AND e.video_asset_id = 0
|
||||||
|
AND split_part(e.video_url, '?', 1) = split_part(t.source_url, '?', 1);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Author: fruit.com.cn
|
||||||
|
-- 转码缩略图轴:默认档位配置 + 单集雪碧图/VTT 元数据(幂等)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS media_thumbnail (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
episode_id BIGINT NOT NULL,
|
||||||
|
drama_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
vtt_object_key VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
sprite_prefix VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
interval_sec INT NOT NULL DEFAULT 1,
|
||||||
|
columns INT NOT NULL DEFAULT 10,
|
||||||
|
rows INT NOT NULL DEFAULT 1,
|
||||||
|
thumb_width INT NOT NULL DEFAULT 160,
|
||||||
|
thumb_height INT NOT NULL DEFAULT 0,
|
||||||
|
frame_count INT NOT NULL DEFAULT 0,
|
||||||
|
duration_sec INT NOT NULL DEFAULT 0,
|
||||||
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'FAILED',
|
||||||
|
error_msg VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted SMALLINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_media_thumbnail_episode
|
||||||
|
ON media_thumbnail (episode_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_thumbnail_drama
|
||||||
|
ON media_thumbnail (drama_id);
|
||||||
|
COMMENT ON TABLE media_thumbnail IS '单集缩略图雪碧图与 VTT 时间轴';
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_media_thumbnail_updated_at ON media_thumbnail;
|
||||||
|
CREATE TRIGGER trg_media_thumbnail_updated_at
|
||||||
|
BEFORE UPDATE ON media_thumbnail
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
INSERT INTO sys_config (id, config_key, config_value, remark) VALUES
|
||||||
|
(2026102201,
|
||||||
|
'media.transcode.profile',
|
||||||
|
'{"enabled":true,"segmentSeconds":6,"ladder":[' ||
|
||||||
|
'{"quality":"360p","width":640,"height":360,"videoBitrateKbps":400,"audioBitrateKbps":64,"enabled":true},' ||
|
||||||
|
'{"quality":"480p","width":854,"height":480,"videoBitrateKbps":800,"audioBitrateKbps":64,"enabled":true},' ||
|
||||||
|
'{"quality":"720p","width":1280,"height":720,"videoBitrateKbps":1600,"audioBitrateKbps":96,"enabled":true},' ||
|
||||||
|
'{"quality":"1080p","width":1920,"height":1080,"videoBitrateKbps":3200,"audioBitrateKbps":128,"enabled":true}],' ||
|
||||||
|
'"sprite":{"enabled":true,"intervalSeconds":1,"columns":10,"thumbWidth":160}}',
|
||||||
|
'转码档位与缩略图轴配置')
|
||||||
|
ON CONFLICT (config_key) DO NOTHING;
|
||||||
+27
-1
@@ -785,6 +785,32 @@ CREATE UNIQUE INDEX IF NOT EXISTS uk_rendition ON media_rendition (media_asset_i
|
|||||||
CREATE INDEX IF NOT EXISTS idx_rendition_episode ON media_rendition (episode_id);
|
CREATE INDEX IF NOT EXISTS idx_rendition_episode ON media_rendition (episode_id);
|
||||||
COMMENT ON TABLE media_rendition IS '多码率产物;播放票据按此生成 master playlist';
|
COMMENT ON TABLE media_rendition IS '多码率产物;播放票据按此生成 master playlist';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS media_thumbnail (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
episode_id BIGINT NOT NULL,
|
||||||
|
drama_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
vtt_object_key VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
sprite_prefix VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
interval_sec INT NOT NULL DEFAULT 1,
|
||||||
|
columns INT NOT NULL DEFAULT 10,
|
||||||
|
rows INT NOT NULL DEFAULT 1,
|
||||||
|
thumb_width INT NOT NULL DEFAULT 160,
|
||||||
|
thumb_height INT NOT NULL DEFAULT 0,
|
||||||
|
frame_count INT NOT NULL DEFAULT 0,
|
||||||
|
duration_sec INT NOT NULL DEFAULT 0,
|
||||||
|
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'FAILED',
|
||||||
|
error_msg VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted SMALLINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_media_thumbnail_episode
|
||||||
|
ON media_thumbnail (episode_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_thumbnail_drama
|
||||||
|
ON media_thumbnail (drama_id);
|
||||||
|
COMMENT ON TABLE media_thumbnail IS '单集缩略图雪碧图与 VTT 时间轴';
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 八、运营补充(PRD 08 平台运营后台 / 10 上线)
|
-- 八、运营补充(PRD 08 平台运营后台 / 10 上线)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -858,7 +884,7 @@ BEGIN
|
|||||||
'wallet', 'product', 'orders', 'store_receipt', 'entitlement', 'refund_record',
|
'wallet', 'product', 'orders', 'store_receipt', 'entitlement', 'refund_record',
|
||||||
'reconcile_task', 'creator', 'creator_document', 'content_submission',
|
'reconcile_task', 'creator', 'creator_document', 'content_submission',
|
||||||
'creator_stat_daily', 'creator_settlement',
|
'creator_stat_daily', 'creator_settlement',
|
||||||
'transcode_task', 'media_rendition',
|
'transcode_task', 'media_rendition', 'media_thumbnail',
|
||||||
'ops_notice', 'ops_daily_stat'
|
'ops_notice', 'ops_daily_stat'
|
||||||
]
|
]
|
||||||
LOOP
|
LOOP
|
||||||
|
|||||||
Reference in new issue
Block a user