添加新功能,完善项目
@@ -1,190 +0,0 @@
|
|||||||
# 【开源推荐】MeBox:把 NAS / 网盘 / 远程 Emby 统一家里的观影入口,Docker 一键部署
|
|
||||||
|
|
||||||
> 配图已托管在 GitHub 仓库(`raw.githubusercontent.com` 直链),发帖时可直接引用,或下载 `docs/tutorial-screenshots/` 后作为附件上传。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 写在前面
|
|
||||||
|
|
||||||
给论坛的朋友们推荐一个我维护的开源项目 —— **MeBox**,一个面向 NAS 与家庭影音场景的**自托管私人媒体中心**(GPL-3.0,Go + React)。
|
|
||||||
|
|
||||||
GitHub:https://github.com/truewhile/MeBox
|
|
||||||
|
|
||||||
一句话介绍:**部署一个服务,同时获得媒体库后台、网盘 STRM 整理、Emby 客户端协议网关三件套。** 内置完整 Emby/Jellyfin 服务端协议实现——手机、电视、平板上的 Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端直接「添加 Emby 服务器」就能连,一套账号体系全搞定,Emby 老用户零学习成本。
|
|
||||||
|
|
||||||
项目 fork 自 MediaStationGo 并持续二开,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 它能解决什么问题?
|
|
||||||
|
|
||||||
家里看电影电视的痛点,MeBox 基本一把梭:
|
|
||||||
|
|
||||||
| 痛点 | MeBox 的解法 |
|
|
||||||
| --- | --- |
|
|
||||||
| 硬盘散落各处,海报墙乱七八糟 | 多根目录媒体库 + TMDb/Bangumi/Douban 自动刮削,海报墙、继续观看、多季剧集一应俱全 |
|
|
||||||
| 网盘资源看一部下一部太麻烦 | OpenList / CloudDrive2 / 115 / WebDAV 接入,STRM 同步 + 直链/302 播放,不占本地空间 |
|
|
||||||
| 已经有一台 Emby,出门还得开 App | **远程 Emby 挂载**:把远程 Emby 的媒体库直接挂进 MeBox 界面统一浏览 |
|
|
||||||
| 家人乱动设置、小孩看不该看的 | 多用户 + 有效期 + 成人内容开关 + 播放配置 PIN,细粒度权限 |
|
|
||||||
| 每个设备装一套专属 App 太折腾 | **完整兼容 Emby/Jellyfin 客户端**:Infuse、SenPlayer、Fileball、官方客户端按「添加 Emby 服务器」填地址 + MeBox 账号即可,海报墙、观看进度、多用户直接同步 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 特点一览
|
|
||||||
|
|
||||||
**1. 现代化 Web UI,海报墙开箱即用**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
深色系登录页,默认账号 `admin / admin123`(首次登录请立即改密)。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
首页自带焦点推荐轮播 + 媒体库入口卡片,继续观看、最近添加直接呈现。
|
|
||||||
|
|
||||||
**2. 媒体库与刮削**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
20 个媒体库、1600+ 条目一眼尽收:每库自带封面拼贴、条目数统计,支持「全库修复+重刮」「刮削队列」批量处理。
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
库内海报墙带评分、集数角标,支持按最后集添加日期排序,点开即看。
|
|
||||||
|
|
||||||
**3. 详情页与多季管理**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
剧情简介、类型标签、多季分集(特别篇/第 1-N 季)、每集缩略图与时长;一键立即播放、调用外部播放器、加入收藏。
|
|
||||||
|
|
||||||
**4. Emby/Jellyfin 客户端无缝兼容**
|
|
||||||
|
|
||||||
这是我最想强调的一点:**MeBox 内置了完整的 Emby 服务端协议实现**。手机、电视、平板上的 Infuse、SenPlayer、Fileball,甚至 Emby/Jellyfin 官方客户端,都不需要任何插件或改造——按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录,海报墙、观看进度、收藏、多用户权限全部无缝衔接。已经习惯 Emby 生态的朋友可以零成本迁移,家人只用电视端 App 也完全无感。
|
|
||||||
|
|
||||||
**5. 网页播放器 + 弹幕自动匹配**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
内置网页播放器支持 HLS 转码、字幕、播放配置档;**弹幕按剧名自动匹配全季分集**(截图中自动匹配到《一拳超人》39 集),屏幕占比/透明度/字号随意调,追新番体验直接拉满。
|
|
||||||
|
|
||||||
**6. 网盘 STRM:网盘当本地盘用**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
添加网盘账号(**115 支持二维码扫码登录**)→ 添加同步目录 → 系统把网盘/本地目录里的视频生成 `.strm` 文件,元数据经下载/上传队列双向同步,播放走直链/302 不落盘。
|
|
||||||
|
|
||||||
**7. 远程 Emby 挂载(特色功能)**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
已有远程 Emby 服务器?填一次账号,按需勾选要挂载的媒体库(支持同服务器多线路自动切换、直连开关、排序),远程库直接出现在 MeBox 首页,不必再开 Emby 客户端。
|
|
||||||
|
|
||||||
**8. 任务队列统一管理**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
刮削 / 下载 / 上传三类任务统一看板,排队中、进行中、已匹配、失败分类计数,支持搜索与批量清理。
|
|
||||||
|
|
||||||
**9. 下载与自动整理**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
配合任意下载器(qBittorrent、Transmission 等下载到本地目录即可),MeBox 定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
|
|
||||||
|
|
||||||
**10. 多用户与权限**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
管理员/普通用户分级、单实例用户数上限、账号有效期、成人内容开关、播放配置 PIN——给家人开号放心给。
|
|
||||||
|
|
||||||
**11. 运维省心**
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
FFmpeg/FFprobe 一键下载安装、转码与硬件加速开关、TMDb 语言、识别词、弹幕、Adult/NSFW 开关全在设置页分组管理;另有 DLNA 投屏、存储统计、海报墙聚合视图:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 使用教程:从零到海报墙只要 5 步
|
|
||||||
|
|
||||||
### 第 1 步:Docker 一键部署
|
|
||||||
|
|
||||||
推荐 Docker Compose(仓库提供 4 份互相独立的完整模板,无需 `.env`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p MeBox && cd MeBox
|
|
||||||
|
|
||||||
# 最省心:单镜像 + 内置 SQLite
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml
|
|
||||||
|
|
||||||
# 多用户/大数据量可选 PostgreSQL 档、Redis 档、OpenSearch 档,见仓库 README「部署档位」
|
|
||||||
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
浏览器访问 `http://服务器IP:18080`,镜像:`ghcr.io/truewhile/mebox:latest`(amd64 / arm64 都有,也提供 Windows/Linux/macOS 单文件可执行程序,不想装 Docker 直接下载跑)。
|
|
||||||
|
|
||||||
### 第 2 步:登录并修改密码
|
|
||||||
|
|
||||||
默认账号 `admin / admin123`,登录后右上角头像 → 个人资料修改密码。
|
|
||||||
|
|
||||||
### 第 3 步:创建媒体库 + 扫库
|
|
||||||
|
|
||||||
后台 → 媒体库 → 管理媒体库,添加本地路径(Docker 部署记得填**容器内**路径,如 `/media/电影`,`volumes` 左侧挂宿主机真实目录)→ 执行扫库。
|
|
||||||
|
|
||||||
### 第 4 步:配置元数据刮削
|
|
||||||
|
|
||||||
系统设置 → 外部 API,填入 TMDb / Bangumi / Douban 等 API Key;媒体库页可对单个库「全库修复+重刮」,刮削进度在任务队列实时可见。
|
|
||||||
|
|
||||||
### 第 5 步(可选但强烈推荐):
|
|
||||||
|
|
||||||
- **网盘用户**:STRM 管理 → 添加网盘账号(115 可扫码)→ 添加同步目录 → 生成 STRM 后直链播放;
|
|
||||||
- **已有 Emby**:Emby 挂载 → 添加 Emby 账号 → 勾选要挂载的媒体库;
|
|
||||||
- **第三方播放器(Emby 客户端全兼容)**:Infuse / SenPlayer / Fileball / Emby、Jellyfin 官方客户端,按「添加 Emby 服务器」填 `http://服务器IP:18080`,用 MeBox 账号登录即可,原有使用习惯完全不变;
|
|
||||||
- **下载党**:qBittorrent 等任意下载器把视频下到下载目录,在文件管理里把它设为整理源,下完自动分类入库。
|
|
||||||
|
|
||||||
### 路径映射小抄(Docker 最常见坑)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- /vol1/1000/Media:/media # 左:宿主机真实路径;右:容器内路径(网页里填这个)
|
|
||||||
environment:
|
|
||||||
MEBOX_MEDIA_DIR: /vol1/1000/Media
|
|
||||||
MEBOX_MEDIA_CONTAINER_DIR: /media
|
|
||||||
```
|
|
||||||
|
|
||||||
硬链接要求同一文件系统/子卷,跨盘请改复制或软链。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 部署档位怎么选?
|
|
||||||
|
|
||||||
| 档位 | 文件 | 组件 | 适合 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 极简 | `docker-compose.simple.yml` | 单镜像 + SQLite | 个人使用、低配设备 |
|
|
||||||
| 标准 | `docker-compose.yml` | + PostgreSQL | 多用户家庭共享 |
|
|
||||||
| 增强 | `docker-compose.standard.yml` | + Redis | 大媒体库高频访问 |
|
|
||||||
| 搜索 | `docker-compose.search.yml` | + OpenSearch | 超大库全文搜索 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术栈与致谢
|
|
||||||
|
|
||||||
- 后端:Go · Gin · GORM · SQLite/PostgreSQL · 可选 Redis / OpenSearch
|
|
||||||
- 前端:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
|
|
||||||
- 部署:Docker Compose 多档模板,amd64/arm64 镜像 + 单文件可执行
|
|
||||||
|
|
||||||
感谢上游 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的奠基,网盘同步/STRM/整理部分参考了 [qmediasync](https://github.com/qicfan/qmediasync) 的思路。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 链接
|
|
||||||
|
|
||||||
- GitHub:https://github.com/truewhile/MeBox
|
|
||||||
- Issue / PR:欢迎提 bug(附部署方式+复现步骤+日志)与功能建议
|
|
||||||
- License:GPL-3.0
|
|
||||||
|
|
||||||
觉得有用的话求个 Star ⭐,也欢迎论坛里的朋友反馈使用体验,我长期维护。
|
|
||||||
@@ -1,743 +0,0 @@
|
|||||||
# 阅读模块(Reading Module)设计与实施方案
|
|
||||||
|
|
||||||
> 状态:设计稿,待评审
|
|
||||||
> 目标版本:v0.2.0(分期落地,见第 9 节)
|
|
||||||
> 关联现有子系统:媒体库 / 网盘存储 / 权限体系 / 任务队列
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 目标与范围
|
|
||||||
|
|
||||||
### 1.1 已确认的产品决策
|
|
||||||
|
|
||||||
| 维度 | 决策 |
|
|
||||||
| --- | --- |
|
|
||||||
| 内容类型 | **电子书 + 漫画统一书架**(EPUB / TXT / PDF / MOBI 与 CBZ / CBR / 图片文件夹) |
|
|
||||||
| 书源 | **独立书库**(不复用影视媒体库)+ **网盘直链阅读** |
|
|
||||||
| 首版范围 | **完整版**:多用户书库权限 + 阅读统计 |
|
|
||||||
| 阅读形态 | **滚动流式与分页翻页双模式**,用户可切换并持久化偏好 |
|
|
||||||
|
|
||||||
### 1.2 明确的非目标
|
|
||||||
|
|
||||||
- **不接入 Emby/Jellyfin 协议。** Emby 的 `Items` / `Views` / `PlaybackInfo` 语义围绕音视频构建,没有书籍章节与阅读进度的对应概念。强行映射会污染 `internal/service/emby_*.go` 与 `internal/handler/emby_*.go` 的既有兼容层,收益极低。阅读能力只通过 MeBox 自己的 Web UI 提供。
|
|
||||||
- **不复用 `model.Library` / `LibraryRoot`。** `Library.Type` 的取值域是 `movie/tv/anime/music`,且被海报墙轮播(`CarouselEnabled`)、自动整理管线、Emby 视图、首页预览等链路消费。把书库塞进去会导致这些链路需要到处加 `type != "book"` 判断。
|
|
||||||
- 首版不做:听书 TTS、在线书源(笔趣阁类)、社交分享、跨设备同步批注冲突合并。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 总体架构
|
|
||||||
|
|
||||||
### 2.1 分层落位
|
|
||||||
|
|
||||||
完全沿用现有分层,不引入新模式:
|
|
||||||
|
|
||||||
```
|
|
||||||
web/src/pages/Books*.tsx ← 页面
|
|
||||||
web/src/components/Book*.tsx ← 阅读器与书架组件
|
|
||||||
web/src/api/books.ts ← axios 封装(仿 web/src/api/library.ts)
|
|
||||||
↓ /api/books/*
|
|
||||||
internal/handler/books*.go ← 反序列化 + 权限校验 + 响应
|
|
||||||
internal/service/book_*.go ← 业务策略(扫描、解析、进度、统计)
|
|
||||||
internal/repository/book_*.go ← 纯持久化
|
|
||||||
internal/model/book.go ← GORM 模型,注册进 model.AllModels()
|
|
||||||
```
|
|
||||||
|
|
||||||
新增路由注册走 `internal/handler/routes_authenticated_features.go` 的既有范式,新增一个 `registerAuthedBookRoutes(authed, svc)`,在 `registerAuthenticatedRoutes` 链上挂载。`service.Container` 与 `repository.Container` 各追加一个字段。
|
|
||||||
|
|
||||||
### 2.2 与现有能力的复用点
|
|
||||||
|
|
||||||
| 现有部件 | 复用方式 |
|
|
||||||
| --- | --- |
|
|
||||||
| `service.StreamService.ServeFile`(`internal/service/stream_file.go`) | 已用 `http.ServeContent` 处理 HEAD / Range / If-Modified-Since,**PDF 与原始文件流直接照搬这条路径** |
|
|
||||||
| `cloud.Provider.Resolve(ctx, fileRef) (*DirectLink, error)`(`internal/service/cloud/cloud.go`) | 网盘书源的直链解析入口,`DirectLink.Proxy` 决定 302 还是反代 |
|
|
||||||
| `model.StorageConfig`(`internal/model/storage_assistant.go`) | 直接复用为网盘书源的账号凭据载体,**不新建凭据表** |
|
|
||||||
| `service.ImageProxy`(`internal/service/image_proxy*.go`) | 漫画页与封面的磁盘缓存 + 远程拉取 + 缩放,复用其缓存目录与命名思路 |
|
|
||||||
| `service.PruneImageCache` / `PruneImageCachePools`(`internal/service/cache_cleanup.go`) | 现成的「按池做 LRU 淘汰 + 按保留时长淘汰」助手,书籍缓存淘汰直接复用它 |
|
|
||||||
| `service/scheduler_local_jobs.go` | 本地定时任务的挂载点,书籍缓存清理与每日统计汇总都注册在这里 |
|
|
||||||
| `config.CacheConfig`(`internal/config/types.go`) | 已有 `CacheDir` / `ImagesMaxSizeMB` / `ImagesOriginalsMaxSizeMB` / `ImagesOriginalsTTLHours` / `MemoryMaxSizeMB`,书籍缓存容量配置直接挂进去 |
|
|
||||||
| `service.FileManager`(`internal/service/filemanager.go`) | 本地书源目录浏览,前端复用 `LocalDirBrowserDialog.tsx` |
|
|
||||||
| `service.Scheduler` | 书库定时扫描(默认关闭,管理员可开) |
|
|
||||||
| `model.UserPermission` | 新增阅读权限位,见第 7 节 |
|
|
||||||
| `helper.Go` / `Container.stopCtx` | 后台扫描任务的生命周期管理 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 数据模型
|
|
||||||
|
|
||||||
新增文件 `internal/model/book.go`,并在 `internal/model/model.go` 的 `AllModels()` 中追加。所有表继承 `model.Base`(UUID 主键 + 时间戳 + 软删除)。
|
|
||||||
|
|
||||||
**表名约定**:`internal/model` 全包**没有任何 `TableName()` 覆盖**,一律使用 GORM 默认复数化(例如 `PlaybackHistory` → `playback_histories`,可从 `internal/database/schema_migration.go` 的裸 SQL 印证)。新表沿用该约定,不引入例外。因此模型命名要保证复数化结果干净:
|
|
||||||
|
|
||||||
| 模型 | 表名 |
|
|
||||||
| --- | --- |
|
|
||||||
| `Book` | `books` |
|
|
||||||
| `BookLibrary` | `book_libraries` |
|
|
||||||
| `BookSource` | `book_sources` |
|
|
||||||
| `BookChapter` | `book_chapters` |
|
|
||||||
| `BookProgress` | `book_progresses` |
|
|
||||||
| `BookAnnotation` | `book_annotations` |
|
|
||||||
| `BookFavorite` | `book_favorites` |
|
|
||||||
| `BookReadingSession` | `book_reading_sessions` |
|
|
||||||
| `BookDailyStat` | `book_daily_stats` |
|
|
||||||
|
|
||||||
(刻意用 `BookDailyStat` 而不是 `BookStatDaily`——后者复数化会得到 `book_stat_dailies`。)
|
|
||||||
|
|
||||||
### 3.1 书库与书源
|
|
||||||
|
|
||||||
```go
|
|
||||||
// BookLibrary 是独立于影视媒体库的书库。
|
|
||||||
type BookLibrary struct {
|
|
||||||
Base
|
|
||||||
Name string `gorm:"size:128;not null" json:"name"`
|
|
||||||
Kind string `gorm:"size:16;not null;default:mixed" json:"kind"` // ebook / comic / mixed
|
|
||||||
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
|
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
|
||||||
SortOrder int `gorm:"index;default:0" json:"sort_order"`
|
|
||||||
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
|
|
||||||
ScanStatus string `gorm:"size:16;default:idle" json:"scan_status"` // idle / scanning / error
|
|
||||||
ScanMessage string `gorm:"size:512" json:"scan_message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BookSource 是书库下的一条挂载来源:本地目录或网盘路径。
|
|
||||||
type BookSource struct {
|
|
||||||
Base
|
|
||||||
LibraryID string `gorm:"index;size:36;not null" json:"library_id"`
|
|
||||||
Name string `gorm:"size:128" json:"name,omitempty"`
|
|
||||||
StorageKind string `gorm:"size:16;not null;default:local" json:"storage_kind"` // local / cloud
|
|
||||||
Path string `gorm:"size:1024;not null" json:"path"` // 本地绝对路径 / 网盘内路径
|
|
||||||
StorageConfigID string `gorm:"index;size:36" json:"storage_config_id,omitempty"` // 复用 model.StorageConfig
|
|
||||||
Depth int `gorm:"default:3" json:"depth"` // 扫描递归深度上限
|
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
|
||||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`StorageKind = cloud` 时,`StorageConfigID` 指向一条 `StorageConfig`(`Type` ∈ `cloud115 / clouddrive2 / openlist / emby_remote`)。凭据解密沿用 `service.CryptoService`。
|
|
||||||
|
|
||||||
### 3.2 书籍与章节
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Book struct {
|
|
||||||
Base
|
|
||||||
LibraryID string `gorm:"index;size:36;not null" json:"library_id"`
|
|
||||||
SourceID string `gorm:"uniqueIndex:uniq_book_source_path,priority:1;index;size:36;not null" json:"source_id"`
|
|
||||||
// SourcePath 在本地源是绝对路径,在网盘源是「网盘内路径」,两者都用
|
|
||||||
// (source_id, source_path) 做唯一键,天然隔离两个 ID 空间。
|
|
||||||
SourcePath string `gorm:"uniqueIndex:uniq_book_source_path,priority:2;size:1024;not null" json:"source_path"`
|
|
||||||
SourceRef string `gorm:"size:256" json:"source_ref,omitempty"` // 网盘 file id / pickcode
|
|
||||||
Title string `gorm:"size:512;not null" json:"title"`
|
|
||||||
Author string `gorm:"size:256;index" json:"author,omitempty"`
|
|
||||||
SeriesName string `gorm:"size:256;index" json:"series_name,omitempty"`
|
|
||||||
Volume int `json:"volume"`
|
|
||||||
Format string `gorm:"size:16;not null" json:"format"` // epub/txt/pdf/mobi/cbz/cbr/folder
|
|
||||||
MediaKind string `gorm:"size:16;not null;default:ebook" json:"media_kind"` // ebook / comic
|
|
||||||
SizeBytes int64 `json:"size_bytes"`
|
|
||||||
FileHash string `gorm:"index;size:64" json:"file_hash,omitempty"` // 大小+首尾采样,去重
|
|
||||||
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
|
|
||||||
Description string `gorm:"type:text" json:"description,omitempty"`
|
|
||||||
Language string `gorm:"size:32" json:"language,omitempty"`
|
|
||||||
Tags string `gorm:"type:text" json:"tags,omitempty"` // 逗号分隔
|
|
||||||
ChapterCount int `json:"chapter_count"`
|
|
||||||
WordCount int64 `json:"word_count"`
|
|
||||||
PageCount int `json:"page_count"` // 漫画总页数 / PDF 页数
|
|
||||||
ParseStatus string `gorm:"size:16;default:pending" json:"parse_status"` // pending/ok/failed
|
|
||||||
ParseMessage string `gorm:"size:512" json:"parse_message,omitempty"`
|
|
||||||
NSFW bool `gorm:"default:false" json:"nsfw"`
|
|
||||||
AddedAt time.Time `json:"added_at"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**唯一键说明**:`SourcePath` 上的 `uniqueIndex` 需与 `SourceID` 组成复合键(`uniq_book_source_path`,priority 1 = `source_id`)。同一本书被两个书源包含时允许重复入库,这是符合预期的(用户可能故意如此)。
|
|
||||||
|
|
||||||
```go
|
|
||||||
// BookChapter 只存索引,不存正文(见 3.4 的取舍)。
|
|
||||||
type BookChapter struct {
|
|
||||||
Base
|
|
||||||
BookID string `gorm:"index:idx_book_chapter,priority:1;size:36;not null" json:"book_id"`
|
|
||||||
Index int `gorm:"index:idx_book_chapter,priority:2" json:"index"`
|
|
||||||
Title string `gorm:"size:512" json:"title"`
|
|
||||||
Level int `gorm:"default:1" json:"level"` // 目录嵌套层级,1 = 顶级
|
|
||||||
// 电子书定位:二选一
|
|
||||||
Href string `gorm:"size:1024" json:"href,omitempty"` // EPUB zip 内条目路径
|
|
||||||
StartOffset int64 `json:"start_offset"` // TXT 字节区间
|
|
||||||
EndOffset int64 `json:"end_offset"`
|
|
||||||
// 漫画/PDF 定位
|
|
||||||
PageStart int `json:"page_start"`
|
|
||||||
PageEnd int `json:"page_end"`
|
|
||||||
CharCount int `json:"char_count"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 进度、批注、收藏、统计
|
|
||||||
|
|
||||||
```go
|
|
||||||
// BookProgress 每个用户每本书一行(复合唯一键,仿 model.PlaybackHistory 的 uniq_user_history 模式)。
|
|
||||||
type BookProgress struct {
|
|
||||||
Base
|
|
||||||
UserID string `gorm:"uniqueIndex:uniq_user_book,priority:1;size:36;not null" json:"user_id"`
|
|
||||||
BookID string `gorm:"uniqueIndex:uniq_user_book,priority:2;size:36;not null" json:"book_id"`
|
|
||||||
ChapterIndex int `gorm:"default:0" json:"chapter_index"`
|
|
||||||
ChapterTitle string `gorm:"size:512" json:"chapter_title,omitempty"`
|
|
||||||
CharOffset int `json:"char_offset"` // 章内字符偏移(电子书)
|
|
||||||
PageIndex int `json:"page_index"` // 页码(漫画 / PDF)
|
|
||||||
Percent float64 `json:"percent"` // 全书百分比,书架进度条展示用
|
|
||||||
ScrollRatio float64 `json:"scroll_ratio"` // 章内滚动比例,跨端还原更精确
|
|
||||||
ReaderMode string `gorm:"size:16;default:scroll" json:"reader_mode"` // scroll / paged
|
|
||||||
Finished bool `gorm:"default:false" json:"finished"`
|
|
||||||
TotalSeconds int64 `json:"total_seconds"`
|
|
||||||
LastReadAt time.Time `gorm:"index" json:"last_read_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BookAnnotation struct {
|
|
||||||
Base
|
|
||||||
UserID string `gorm:"index:idx_book_anno,priority:1;size:36;not null" json:"user_id"`
|
|
||||||
BookID string `gorm:"index:idx_book_anno,priority:2;size:36;not null" json:"book_id"`
|
|
||||||
ChapterIndex int `json:"chapter_index"`
|
|
||||||
Type string `gorm:"size:16;not null" json:"type"` // bookmark / highlight / note
|
|
||||||
StartOffset int `json:"start_offset"`
|
|
||||||
EndOffset int `json:"end_offset"`
|
|
||||||
SelectedText string `gorm:"size:2048" json:"selected_text,omitempty"`
|
|
||||||
Note string `gorm:"type:text" json:"note,omitempty"`
|
|
||||||
Color string `gorm:"size:16" json:"color,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BookFavorite struct {
|
|
||||||
Base
|
|
||||||
UserID string `gorm:"uniqueIndex:uniq_user_book_fav,priority:1;size:36;not null" json:"user_id"`
|
|
||||||
BookID string `gorm:"uniqueIndex:uniq_user_book_fav,priority:2;size:36;not null" json:"book_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BookReadingSession 由前端心跳驱动,服务端按小时聚合,避免行数爆炸。
|
|
||||||
type BookReadingSession struct {
|
|
||||||
Base
|
|
||||||
UserID string `gorm:"index:idx_book_stat,priority:1;size:36;not null" json:"user_id"`
|
|
||||||
BookID string `gorm:"index;size:36;not null" json:"book_id"`
|
|
||||||
BucketStart time.Time `gorm:"index:idx_book_stat,priority:2" json:"bucket_start"` // 截断到小时
|
|
||||||
Seconds int64 `json:"seconds"`
|
|
||||||
CharsRead int64 `json:"chars_read"`
|
|
||||||
PagesRead int `json:"pages_read"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BookDailyStat 每日汇总,供热力图与「年度阅读报告」查询,避免实时扫 session 表。
|
|
||||||
type BookDailyStat struct {
|
|
||||||
Base
|
|
||||||
UserID string `gorm:"uniqueIndex:uniq_user_book_daily,priority:1;size:36;not null" json:"user_id"`
|
|
||||||
Day string `gorm:"uniqueIndex:uniq_user_book_daily,priority:2;size:10;not null" json:"day"` // YYYY-MM-DD
|
|
||||||
Seconds int64 `json:"seconds"`
|
|
||||||
Chars int64 `json:"chars"`
|
|
||||||
Pages int `json:"pages"`
|
|
||||||
Books int `json:"books"` // 当日有阅读记录的书数
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 关键取舍:正文不入库
|
|
||||||
|
|
||||||
**决策:DB 只存章节索引(偏移量 / zip 内路径 / 页码区间),正文按需从源文件读取。**
|
|
||||||
|
|
||||||
理由:
|
|
||||||
1. 网文 TXT 常见 5–50MB,漫画单册 100–800MB。入库会让 SQLite 单文件膨胀到数十 GB,直接冲击 `docker-compose.simple.yml` 的「单文件数据库好备份」定位,也会拖慢全库 VACUUM / 备份 / 数据库迁移(`internal/service/database_admin.go`)。
|
|
||||||
2. 源文件本来就是权威副本,重复存储没有收益。
|
|
||||||
3. EPUB 与 CBZ 本质上都是 zip,**随机读取 zip 内单个条目成本极低**(读中央目录 + 解压目标条目),不需要把整本解压落盘。
|
|
||||||
|
|
||||||
代价是每次打开章节都要读源文件。缓解手段:
|
|
||||||
- 本地源:`os.Open` + `io.SectionReader`,代价可忽略。
|
|
||||||
- 网盘源:见 4.3 的本地缓存策略,且对已缓存的章节走本地。
|
|
||||||
|
|
||||||
### 3.5 用户级字段(挂在 `model.User` 上)
|
|
||||||
|
|
||||||
沿用 `AllowedLibraryIDs` 的 JSON-in-text 模式(见 `internal/model/user.go`),**不复用影视库字段**,避免两个 ID 空间交叉:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 追加到 model.User
|
|
||||||
ReaderSettings string `gorm:"type:text" json:"-"` // 阅读器偏好 JSON
|
|
||||||
AllowedBookLibraryIDs string `gorm:"type:text" json:"-"` // 空 = 不限制
|
|
||||||
AllowedBookLibraryList []string `gorm:"-" json:"allowed_book_library_ids,omitempty"`
|
|
||||||
```
|
|
||||||
|
|
||||||
`ReaderSettings` 结构(前端读写,服务端仅透传与长度校验):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mode": "scroll|paged",
|
|
||||||
"fontSize": 18,
|
|
||||||
"lineHeight": 1.8,
|
|
||||||
"fontFamily": "serif|sans|custom",
|
|
||||||
"contentWidth": 720,
|
|
||||||
"theme": "light|sepia|dark|black",
|
|
||||||
"pageAnimation": "slide|fade|none",
|
|
||||||
"comicLayout": "single|double|auto",
|
|
||||||
"comicDirection": "ltr|rtl",
|
|
||||||
"hideScrollbar": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
放在 `User` 行内(而非新表)的理由:与 `PlayerVolume` / `DanmakuFontSize` 等既有播放器偏好一致,读取时随用户信息一并返回,无需额外查询。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 书源与内容读取管线
|
|
||||||
|
|
||||||
### 4.1 扫描流程
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/books/libraries/:id/scan
|
|
||||||
→ BookScannerService.ScanLibrary(ctx, libraryID)
|
|
||||||
1. 置 ScanStatus=scanning,通过 SSEHub 广播进度(复用 service.SSEHub)
|
|
||||||
2. 遍历启用的 BookSource
|
|
||||||
- local: filepath.WalkDir,按扩展名白名单过滤,超过 Depth 停止递归
|
|
||||||
- cloud: cloud.New(cfg.Type, cfg, client).List(ctx, dirID) 递归列目录
|
|
||||||
3. 对每个候选文件调 BookParser.ParseMeta(reader) 拿元信息 + 目录
|
|
||||||
4. Upsert 到 books / book_chapters(source_id + source_path 为幂等键)
|
|
||||||
5. 源上已消失的书标记软删除(与影视库扫描语义保持一致)
|
|
||||||
6. 置 ScanStatus=idle,记录 LastScanAt
|
|
||||||
```
|
|
||||||
|
|
||||||
扩展名白名单:`.epub .txt .pdf .mobi .azw3 .cbz .cbr .zip .rar`(`.zip/.rar` 仅当目录内全是图片时按漫画处理,否则跳过,防止误吞压缩包)。
|
|
||||||
|
|
||||||
并发:复用 `internal/service` 现有的 worker 池写法,默认 2–4 并发解析(解析要读文件,IO 密集)。
|
|
||||||
|
|
||||||
### 4.2 各格式解析策略
|
|
||||||
|
|
||||||
| 格式 | 元信息 | 章节 / 页 | 正文读取 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| **EPUB** | zip → `META-INF/container.xml` → OPF → `dc:title/dc:creator/dc:language/dc:description`;封面取 OPF `meta[name=cover]` 指向项,退化到 `guide` | 按 spine 顺序,标题取每个 XHTML 的 `<title>` 或首个 `h1..h3`;`Level` 由 nav/ncx 的嵌套深度推断 | `archive/zip` 定位 `Href` 条目,读出 XHTML → 服务端清洗后返回 |
|
|
||||||
| **TXT** | 文件名(`书名 - 作者.txt` 模式)+ 编码探测 | 正则切分:`第[一二三四五六七八九十百千零两0-9]+[章节卷回篇]`、`Chapter\s+\d+`、`^\s*\d+\s*$`;命中不足 3 个则按固定字节窗口切片 | `io.SectionReader` 读 `[StartOffset, EndOffset)` → 按探测到的编码转 UTF-8 |
|
|
||||||
| **PDF** | 首页/元数据(页数、标题);封面渲染首屏,失败则留空 | 单章「正文」,`PageStart/PageEnd` = 全书页 | 原始文件流(Range),前端 pdf.js 自己解析 |
|
|
||||||
| **CBZ / CBR** | zip/rar 条目自然排序,第一张图做封面 | 单章,页区间 = 图片条目序号 | 按页解压单条目,走图片响应路径 |
|
|
||||||
| **图片文件夹** | 目录名 | 单章,页区间 = 排序后图片序号 | 直接读本地文件 |
|
|
||||||
| **MOBI / AZW3** | PalmDOC / KF8 头 | 首版**只入库展示、不支持在线阅读**,详情页给出「下载原文件」入口 | — |
|
|
||||||
|
|
||||||
实现细节提示:
|
|
||||||
- 编码探测用 `golang.org/x/text`(已是 `go.mod` 间接依赖)。GBK/Big5/UTF-16LE 都要覆盖,中文网文 TXT 大量是 GBK。
|
|
||||||
- CBR 需要 RAR 解压。建议引入纯 Go 的 `github.com/nwaples/rardecode`;若不接受新依赖,首版把 CBR 归入「只入库、不可读」。
|
|
||||||
- EPUB XHTML 清洗**必须在服务端做**:剔除 `<script>`、`on*` 事件属性、`<iframe>`、外部 `http(s)` 资源引用,把 `src/href` 重写为 `/api/books/:id/res/*`。前端再叠一层 DOMPurify 作为纵深防御。
|
|
||||||
|
|
||||||
### 4.3 网盘书籍的读取策略
|
|
||||||
|
|
||||||
网盘直链的核心约束:**EPUB / CBZ 的解析必须能读到文件尾部**(zip 中央目录在末尾),但 `cloud.Provider.Resolve` 返回的是短时效 URL,且 115 直链依赖 UA/Cookie(`DirectLink.Headers`),浏览器无法直接携带。
|
|
||||||
|
|
||||||
因此分两条路径:
|
|
||||||
|
|
||||||
**A. 解析阶段 —— 完整拉取到缓存目录**
|
|
||||||
|
|
||||||
```
|
|
||||||
<CacheDir>/books/<sourceID>/<hash>.<ext>
|
|
||||||
```
|
|
||||||
|
|
||||||
`BookParser` 通过 `DirectLink` 拉全量文件到缓存后再解析。缓存目录复用 `config.CacheConfig.CacheDir`(默认 `<DataDir>/cache`,容器里是 `/cache`),容量上限新加一项 `CacheConfig.BooksMaxSizeMB`(默认 2GB),走 LRU 淘汰。缓存命中的书后续正文读取也直接走本地,不再回网盘。
|
|
||||||
|
|
||||||
**B. 阅读阶段 —— 优先本地缓存,未命中走代理流**
|
|
||||||
|
|
||||||
未缓存时由服务端反代目标 URL(`DirectLink.Proxy=true` 时同样反代),并把 `Content-Type: image/*` 或 `application/pdf` 透传给前端。反代实现直接参照 `internal/service/cloud115_hls_proxy.go` 的响应头透传白名单(`Content-Type/Content-Length/Content-Range/Accept-Ranges/ETag/Last-Modified`)。
|
|
||||||
|
|
||||||
**C. 阅读进度与文件解耦** —— 代码里区分「源」「位置」:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type BookLocator struct {
|
|
||||||
Kind string `json:"kind"` // local / cloud
|
|
||||||
LocalPath string `json:"local_path,omitempty"`
|
|
||||||
CloudConfig string `json:"cloud_config,omitempty"`
|
|
||||||
CloudRef string `json:"cloud_ref,omitempty"`
|
|
||||||
Href string `json:"href,omitempty"` // zip 内条目
|
|
||||||
StartOffset int64 `json:"start_offset,omitempty"`
|
|
||||||
EndOffset int64 `json:"end_offset,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
被缓存的书 `Kind` 仍报 `cloud`(进度不绑物理位置),这样缓存被淘汰后进度依然有效。这是不把 `Book.Path` 直接存成本地缓存路径的原因。
|
|
||||||
|
|
||||||
### 4.4 磁盘与容器
|
|
||||||
|
|
||||||
书籍目录需要在 compose 里挂载,并在 README 的部署档位表补充说明。新缓存目录复用现有 `MEBOX_CACHE_CACHE_DIR`(`docker-compose.simple.yml` 中为 `/cache`),无需新增环境变量。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. HTTP API 设计
|
|
||||||
|
|
||||||
全部挂在 `/api/books/*`,注册在 `registerAuthedBookRoutes`。响应统一走 `internal/handler/response.go` 的既有助手。
|
|
||||||
|
|
||||||
### 5.1 书库与扫描(管理端)
|
|
||||||
|
|
||||||
| 方法 | 路径 | 权限 | 说明 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| GET | `/api/books/libraries` | `can_read_books` | 列表,按 `AllowedBookLibraryIDs` 过滤可见性 |
|
|
||||||
| POST | `/api/books/libraries` | `can_manage_book_library` | 新建/更新书库 |
|
|
||||||
| DELETE | `/api/books/libraries/:id` | `can_manage_book_library` | 删除(含级联软删 books) |
|
|
||||||
| GET | `/api/books/libraries/:id/sources` | `can_manage_book_library` | 书源列表 |
|
|
||||||
| POST | `/api/books/libraries/:id/sources` | `can_manage_book_library` | 新增书源(本地目录 / 网盘路径) |
|
|
||||||
| POST | `/api/books/libraries/:id/scan` | `can_manage_book_library` | 触发扫描,返回 task id |
|
|
||||||
| GET | `/api/books/scan/status` | `can_manage_book_library` | 扫描进度轮询 |
|
|
||||||
| GET | `/api/books/browse` | `can_manage_book_library` | 网盘路径浏览(复用 cloud Provider.List) |
|
|
||||||
|
|
||||||
### 5.2 书架与详情
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| GET | `/api/books` | 书架列表。参数:`library_id`、`keyword`、`media_kind`、`format`、`tag`、`sort`(`title/added_at/last_read/progress`)、`page/page_size` |
|
|
||||||
| GET | `/api/books/continue-reading` | 最近在读,首页「继续阅读」区块用 |
|
|
||||||
| GET | `/api/books/:id` | 详情(元信息 + 目录树 + 当前用户进度 + 收藏态) |
|
|
||||||
| GET | `/api/books/:id/cover` | 封面。走 `ImageProxy` 的缓存与缩放,参数 `w` |
|
|
||||||
| GET | `/api/books/:id/chapters/:index` | 章节正文。电子书返回 `text/html`(已清洗)或 `application/json` 结构化段落 |
|
|
||||||
| GET | `/api/books/:id/res/*path` | EPUB 内部资源(图片/字体/CSS),路径参数为 zip 内条目 |
|
|
||||||
| GET | `/api/books/:id/pages/:index` | 漫画单页图片,`Content-Type: image/*` + 长效缓存头 |
|
|
||||||
| GET | `/api/books/:id/file` | 原始文件流(Range),pdf.js 与「下载原文件」共用 |
|
|
||||||
| POST | `/api/books/:id/favorite` | 收藏 / 取消收藏 |
|
|
||||||
| DELETE | `/api/books/:id` | 删除(`can_manage_books`) |
|
|
||||||
|
|
||||||
**章节响应格式(推荐 JSON 而非裸 HTML)**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"index": 12,
|
|
||||||
"title": "第十二章 雨夜",
|
|
||||||
"char_count": 3820,
|
|
||||||
"blocks": [
|
|
||||||
{ "type": "p", "text": "……" },
|
|
||||||
{ "type": "img", "src": "/api/books/xxx/res/images/1.png" }
|
|
||||||
],
|
|
||||||
"next_index": 13,
|
|
||||||
"prev_index": 11
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
用结构化 blocks 而非 HTML 的理由:
|
|
||||||
1. 前端可安全渲染,不必 `dangerouslySetInnerHTML`,彻底绕开 XSS 面。
|
|
||||||
2. 分页模式需要按节点测量高度做分栏,结构化的段落数组比操作 DOM 简单得多。
|
|
||||||
3. 字号/行距/主题切换只需重渲染,不碰 HTML 字符串。
|
|
||||||
|
|
||||||
保底方案:`?format=html` 仍返回清洗后的 HTML,供 EPUB 中复杂排版(表格、脚注、双向文字)回退。
|
|
||||||
|
|
||||||
### 5.3 进度、批注、统计
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| GET | `/api/books/:id/progress` | 当前用户进度 |
|
|
||||||
| PUT | `/api/books/:id/progress` | 保存进度。前端**节流 5s + 页面卸载时 `sendBeacon`** |
|
|
||||||
| GET | `/api/books/:id/annotations` | 批注列表 |
|
|
||||||
| POST | `/api/books/:id/annotations` | 新建 |
|
|
||||||
| PATCH | `/api/books/:id/annotations/:aid` | 修改 note / color |
|
|
||||||
| DELETE | `/api/books/:id/annotations/:aid` | 删除 |
|
|
||||||
| POST | `/api/books/:id/heartbeat` | 阅读心跳,body 为 `{seconds, chars, pages}`,落 `book_reading_sessions` 小时桶 |
|
|
||||||
| GET | `/api/books/reader-settings` | 读取当前用户的阅读器偏好(`model.User.ReaderSettings`) |
|
|
||||||
| PUT | `/api/books/reader-settings` | 保存阅读器偏好(服务端只做长度与枚举校验后原样存储) |
|
|
||||||
| GET | `/api/books/stats` | 个人统计:累计时长、已读书数、在读、近 30 天热力图 |
|
|
||||||
| GET | `/api/books/stats/overview` | 管理员视角:全站阅读统计(`can_access_settings`) |
|
|
||||||
|
|
||||||
进度写入的并发安全:`uniq_user_book` 复合唯一键 + 先 `Updates` 再 `Create` 的 upsert 模式。**参照 `internal/database/schema_migration.go` 里 `dedupePlaybackHistories` 的前车之鉴**——`PlaybackHistory` 曾因 read-then-write 产生重复行导致唯一索引进不去,新表直接写 upsert,不要复制那个 bug。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 前端设计
|
|
||||||
|
|
||||||
### 6.1 路由与导航
|
|
||||||
|
|
||||||
`web/src/appRoutes.tsx` 新增懒加载路由:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const BookshelfPage = lazy(() => import('./pages/BookshelfPage').then(m => ({ default: m.BookshelfPage })))
|
|
||||||
const BookDetailPage = lazy(() => import('./pages/BookDetailPage').then(m => ({ default: m.BookDetailPage })))
|
|
||||||
const BookReaderPage = lazy(() => import('./pages/BookReaderPage').then(m => ({ default: m.BookReaderPage })))
|
|
||||||
const BookStatsPage = lazy(() => import('./pages/BookStatsPage').then(m => ({ default: m.BookStatsPage })))
|
|
||||||
const BookLibraryPage = lazy(() => import('./pages/BookLibraryPage').then(m => ({ default: m.BookLibraryPage })))
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
/books 书架
|
|
||||||
/books/:id 书籍详情(目录、元信息、开始阅读)
|
|
||||||
/books/:id/read 阅读器(全屏,隐藏底栏)
|
|
||||||
/books/stats 阅读统计
|
|
||||||
/books/library 书库管理(adminOnly)
|
|
||||||
```
|
|
||||||
|
|
||||||
`web/src/components/layoutNavigation.ts` 的改动:
|
|
||||||
|
|
||||||
- `MEDIA_NAV_ITEMS` 与 `MOBILE_BOTTOM_NAV_ITEMS` 加「阅读」项(`BookOpen` 图标),普通用户可见。
|
|
||||||
- 新增 `isBookReaderRoute(pathname)`,并在 `shouldShowMobileBottomNav` 中排除 `/books/:id/read`,与 `isPlayerRoute` 的处理一致。
|
|
||||||
- `resolveHeaderBack` 补 `/books/...` 的返回链。
|
|
||||||
- `LAYOUT_NAV_ITEMS` 加「书库管理」条目,`adminOnly: true`。
|
|
||||||
|
|
||||||
### 6.2 页面组成
|
|
||||||
|
|
||||||
```
|
|
||||||
web/src/pages/
|
|
||||||
BookshelfPage.tsx 书架:筛选栏 + 网格/列表双视图 + 继续阅读横滑
|
|
||||||
BookDetailPage.tsx 详情:封面、元信息、目录树、进度、开始/继续阅读
|
|
||||||
BookReaderPage.tsx 阅读器外壳:顶栏 + 内容区 + 底部工具条 + 设置抽屉
|
|
||||||
BookStatsPage.tsx 统计:热力图 + 概览卡片
|
|
||||||
BookLibraryPage.tsx 书库管理:书库 CRUD + 书源 CRUD + 扫描触发与进度
|
|
||||||
web/src/components/
|
|
||||||
book/ReaderCore.tsx 渲染内核分发(按 media_kind + format)
|
|
||||||
book/ScrollReader.tsx 滚动流式
|
|
||||||
book/PagedReader.tsx 分页翻页
|
|
||||||
book/ComicReader.tsx 漫画(单页/双页/右开本/预加载)
|
|
||||||
book/PdfReader.tsx PDF(pdf.js)
|
|
||||||
book/ReaderToolbar.tsx 顶栏 + 底栏(章节、进度、目录、批注、设置)
|
|
||||||
book/ReaderSettingsPanel.tsx 阅读设置
|
|
||||||
book/ChapterTocDrawer.tsx 目录抽屉
|
|
||||||
book/AnnotationList.tsx 书签笔记列表
|
|
||||||
book/ReaderProgressBar.tsx 进度条(可拖拽跳章)
|
|
||||||
web/src/api/books.ts 接口封装
|
|
||||||
```
|
|
||||||
|
|
||||||
对于 4 类内容,`ReaderCore` 的分发是第一层决策:
|
|
||||||
|
|
||||||
| `media_kind` | `format` | 内核 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| ebook | epub / txt | `ScrollReader` 或 `PagedReader`(按 `settings.mode`) |
|
|
||||||
| ebook | pdf | `PdfReader` |
|
|
||||||
| comic | cbz / cbr / folder | `ComicReader` |
|
|
||||||
| ebook | mobi / azw3 | 不提供阅读,仅详情页 |
|
|
||||||
|
|
||||||
### 6.3 渲染内核选型(关键决策)
|
|
||||||
|
|
||||||
**结论:自研内核,不引入 epub.js / foliate-js。**
|
|
||||||
|
|
||||||
对比:
|
|
||||||
|
|
||||||
| 方案 | 优点 | 缺点 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `epub.js` | 成熟、CFI 精确定位、多列分页开箱即用 | 维护停滞;CFI 定位难以与自研进度模型(`charOffset` / `percent`)对齐;PDF/漫画仍需另做两个内核 |
|
|
||||||
| `foliate-js` | 一套 API 覆盖 EPUB/MOBI/CBZ/PDF,排版质量高 | 生态小、文档薄、非稳定 API,需要 vendored 一份并自行承担升级风险 |
|
|
||||||
| **自研(推荐)** | 进度模型完全可控、跨端一致;零新增重依赖;与既有 Tailwind 主题体系天然统一 | 需要自己实现分页测量、脏 HTML 清洗、资源重写 |
|
|
||||||
|
|
||||||
自研方案的可行性依据:分页的本质是「CSS multi-column 布局 + `transform: translateX` 平移」,foliate-js 也是这么做的,核心约 200 行;滚动模式的虚拟化可以直接复用已有的 `react-virtuoso`(已在 `web/package.json`,用于 `VirtualMediaGrid`)。
|
|
||||||
|
|
||||||
自研必须做好的三件事:
|
|
||||||
|
|
||||||
1. **HTML 清洗**:服务端为主(见 4.2),前端用 `dompurify` 兜底。这是新增的唯一运行时依赖。
|
|
||||||
2. **资源重写**:EPUB 内部图片/字体/CSS 的 `src`、`href`、`url()` 全部重写到 `/api/books/:id/res/`,否则相对路径会 404。
|
|
||||||
3. **分页测量与重排**:容器尺寸变化(窗口 resize、字号切换、横竖屏)后必须重新分页,并把「当前段落 + 段内比例」作为锚点恢复位置,不能让用户跳回章首。
|
|
||||||
|
|
||||||
### 6.4 双模式实现
|
|
||||||
|
|
||||||
**滚动模式(`ScrollReader`)**
|
|
||||||
- 章内虚拟化:单章文本通常 2k–10k 字,直接整章渲染即可;跨章用「当前章 + 前后各一章」的窗口,滚动到边界时无缝追加。
|
|
||||||
- 进度:`IntersectionObserver` 观测可视段落,映射为 `charOffset`;`scroll_ratio` 同时上报。
|
|
||||||
- 优势:移动端体验好,实现简单,长段落无分页误差。
|
|
||||||
|
|
||||||
**分页模式(`PagedReader`)**
|
|
||||||
- 章内:容器设为多列(`column-width: <contentWidth>`),`overflow: hidden`,通过 `translateX` 翻页;总页数由 `scrollWidth / containerWidth` 得出。
|
|
||||||
- 跨章:翻到本章末尾自动加载下一章首页;反向同理。章首/章尾需处理「残页合并」,避免出现半屏空白页。
|
|
||||||
- 输入:左右方向键、空格、点击左右热区、滑动手势(移动端)。`comicDirection`/`pageAnimation` 控制方向与动画。
|
|
||||||
- 进度:`chapter_index` + `page_index` 映射回 `charOffset`。
|
|
||||||
|
|
||||||
两种模式共享 `BookProgress`,切换模式时用「章 + 比率」换算,不丢位置。
|
|
||||||
|
|
||||||
### 6.5 状态与持久化
|
|
||||||
|
|
||||||
- 阅读器设置来自 `authStore` 的用户信息(`ReaderSettings` 反序列化),改动后 `PUT /api/books/reader-settings` 持久化 + 本地 `localStorage` 兜底(首屏渲染不等接口)。
|
|
||||||
- 进度本地先写 `localStorage`(key `mebook:book:<id>:pos`),再节流同步服务端;页面隐藏/卸载用 `navigator.sendBeacon` 保证不丢。
|
|
||||||
- 新增 `web/src/stores/readerSettings.ts`(zustand),与既有 `playProfile.ts` 组织方式一致。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 权限与多用户
|
|
||||||
|
|
||||||
`model.UserPermission` 新增 4 位(当前 18 个字段,加后 22 位):
|
|
||||||
|
|
||||||
| 权限位 | 默认 | 含义 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `can_read_books` | `true` | 书架、阅读、进度、批注 |
|
|
||||||
| `can_manage_book_library` | `false` | 书库 / 书源 CRUD、触发扫描、网盘浏览 |
|
|
||||||
| `can_manage_books` | `false` | 编辑书籍元信息、删除书、手动重新解析 |
|
|
||||||
| `can_view_book_stats` | `false` | 查看全站阅读统计 |
|
|
||||||
|
|
||||||
同步改动清单(**漏一处就会出现「后端有权限、前端不显示开关」的静默 bug**):
|
|
||||||
|
|
||||||
1. `internal/model/permission.go` — 字段、`NewDefaultPermission()`、`PermissionMap()`,并更新文件头注释里的数量描述(注释目前写「19项」,实际 18 个字段,顺手修正)。
|
|
||||||
2. `web/src/types/auth.ts` — `PermissionFlags` 接口加 4 个字段。
|
|
||||||
3. `web/src/stores/permissions.ts` — 默认值对象、中文标签映射、权限分组数组。
|
|
||||||
4. `web/src/hooks/usePermission.ts` — 若其中有分组注释需同步。
|
|
||||||
5. `internal/handler/permissions.go` — 权限矩阵响应(若有枚举)。
|
|
||||||
6. `web/src/pages/AdminUsersForm.tsx` / 权限勾选 UI — 若按分组硬编码了列表。
|
|
||||||
|
|
||||||
书库可见性:
|
|
||||||
|
|
||||||
- 管理员在用户管理页勾选该用户可访问的书库,写入 `User.AllowedBookLibraryIDs`。
|
|
||||||
- 空值 = 不限制(与影视库语义一致)。
|
|
||||||
- 过滤集中在一个 `bookVisibility` 助手,与现有的 `internal/handler/visibility.go` 并列(该文件就是影视库可见性的集中判定点,并且会与 `PlayProfile.AllowedLibraryIDs` 求交集)。**阅读模块首版不接播放配置档**——`PlayProfile` 是影视播放器概念(音量、转码参数、PIN),与阅读无关;但判定入口要与它放在同一层,将来若要按配置档限制书库才不用重构。
|
|
||||||
- **服务端强制**:`GET /api/books/:id`、章节、页面、资源(`/res/*`)、封面、原始文件流,**所有**按 ID 取内容的接口都要校验 `book.LibraryID ∈ 用户可见书库`,不能只靠书架列表过滤。这是最容易漏的越权点:`/api/books/:id/res/*path` 会直接吐出书籍内部的原始资源,漏校验等于开放全库文件读取。
|
|
||||||
- 用户被取消书库授权后,其 `BookProgress` / `BookAnnotation` 保留不删(授权恢复即恢复),但接口一律按当前可见性判定,不因历史数据放行。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 阅读统计
|
|
||||||
|
|
||||||
- **采集**:阅读器每 30s 发一次 `heartbeat`,卸载时补发一次;服务端按 `(user_id, book_id, 小时桶)` 累加,行数上限 = 用户数 × 书数 × 阅读小时数,可控。
|
|
||||||
- **汇总**:`Scheduler` 每日 03:00 把昨天之前的 session 滚进 `BookDailyStat`(复用 `service.Scheduler` 的既有定时任务注册方式)。
|
|
||||||
- **展示**:
|
|
||||||
- 个人页「阅读统计」:累计时长、读完本数、在读本数、近 30 天热力图(仿 GitHub 贡献图)、阅读类型分布(电子书 / 漫画)。
|
|
||||||
- 首页新增「继续阅读」横滑区块(参照 `HomePageSections.tsx` 里既有区块的写法)。
|
|
||||||
- 管理员视图:全站活跃度、热门书籍 Top 20(需 `can_view_book_stats`)。
|
|
||||||
|
|
||||||
隐私:统计仅对本人与管理员可见;管理员视图只出聚合数据,不暴露单个用户的阅读内容。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 分期实施计划
|
|
||||||
|
|
||||||
### P0 — 端到端可用(本地书库 / EPUB + TXT 电子书)
|
|
||||||
|
|
||||||
目标:能扫库、能在网页上把一本书读完、关掉浏览器再打开能续读。
|
|
||||||
|
|
||||||
| # | 交付物 |
|
|
||||||
| --- | --- |
|
|
||||||
| 1 | `internal/model/book.go` 九张表 + `AllModels()` 注册 + 迁移验证(SQLite 与 PostgreSQL 各跑一次升级) |
|
|
||||||
| 2 | `BookLibrary` / `BookSource` / `Book` / `BookChapter` / `BookProgress` 的 repository |
|
|
||||||
| 3 | `BookParser`:EPUB 与 TXT 解析(含 GBK 编码探测、章节正则切分、封面提取) |
|
|
||||||
| 4 | `BookScannerService`:本地目录扫描 + upsert + 进度广播 |
|
|
||||||
| 5 | API:书库 CRUD、书源 CRUD、扫描、书架列表、详情、章节正文、封面、进度读写、阅读器偏好读写 |
|
|
||||||
| 6 | 前端:`BookshelfPage`、`BookDetailPage`、`BookReaderPage`(仅滚动模式)、目录抽屉、阅读设置面板 |
|
|
||||||
| 7 | 权限:4 个权限位 + `AllowedBookLibraryIDs` 全链路(含服务端越权校验) |
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- 一个含 50 本 EPUB 与 20 本 GBK 编码 TXT 的目录,扫描后书架正确列出,标题/作者/封面/章节目录无误。
|
|
||||||
- 任意一本书可连续阅读 3 章以上,刷新页面后回到原位置(误差 < 1 段)。
|
|
||||||
- 权限为 `can_read_books=false` 的账号访问 `/api/books` 返回 403;直接请求他人书库的 `/api/books/:id/chapters/0`、`/api/books/:id/res/*`、`/api/books/:id/file` 同样被拒。
|
|
||||||
- SQLite 单文件档与 PostgreSQL 档都能从旧版本升级启动,无迁移报错。
|
|
||||||
|
|
||||||
### P1 — 漫画 + 分页模式 + 网盘直链
|
|
||||||
|
|
||||||
| # | 交付物 |
|
|
||||||
| --- | --- |
|
|
||||||
| 1 | `ComicReader`:CBZ 解析、单页/双页、右开本、相邻页预加载 |
|
|
||||||
| 2 | `PagedReader`:分页测量、resize 重排、跨章衔接、键鼠与手势输入 |
|
|
||||||
| 3 | 网盘书源:`StorageKind=cloud` 的书源配置、`cloud.Provider` 接入、本地缓存目录 + LRU 淘汰 |
|
|
||||||
| 4 | 网盘书籍的索引拉取与阅读反代(含 `Content-Range` 透传) |
|
|
||||||
| 5 | PDF:`PdfReader`(pdf.js)+ Range 文件流接口 |
|
|
||||||
| 6 | 图片文件夹型漫画 |
|
|
||||||
|
|
||||||
**验收标准**
|
|
||||||
- CBZ 单册 300 页可流畅翻阅,双页模式断页处理正确(避免跨章错配)。
|
|
||||||
- 分页模式下切换字号、resize 窗口、手机横竖屏切换后,位置不跳、不出现空白页。
|
|
||||||
- 挂在 OpenList 与 115 上的 EPUB 能正常入库并在线阅读,缓存目录达到上限后按 LRU 淘汰且不影响已有进度。
|
|
||||||
- 20MB 以上 PDF 可跳页、可缩放。
|
|
||||||
|
|
||||||
### P2 — 批注、统计与体验打磨
|
|
||||||
|
|
||||||
| # | 交付物 |
|
|
||||||
| --- | --- |
|
|
||||||
| 1 | 划线 / 书签 / 笔记:`BookAnnotation` 接口与 UI,批注列表与跳转 |
|
|
||||||
| 2 | 阅读统计:心跳采集、每日汇总任务、个人统计页、首页「继续阅读」区块 |
|
|
||||||
| 3 | 管理员统计视图 + 热门书籍排行 |
|
|
||||||
| 4 | 书库定时扫描(`Scheduler` 接入,默认关闭) |
|
|
||||||
| 5 | 书架高级筛选与排序、合集(系列)聚合视图 |
|
|
||||||
| 6 | MOBI/AZW3 元信息解析(仍不做在线阅读,仅提供下载) |
|
|
||||||
| 7 | 部署文档与 compose 注释更新(书籍目录挂载说明) |
|
|
||||||
|
|
||||||
### P3 — 可选增强
|
|
||||||
听书 TTS、跨设备批注冲突合并、书源自动整理(仿 `OrganizerService`)、EPUB 阅读器内注释锚点高亮。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 风险与待拍板项
|
|
||||||
|
|
||||||
### 10.1 需要你拍板的两点
|
|
||||||
|
|
||||||
**① 网盘书籍的缓存策略**
|
|
||||||
- 选项 A(本方案):索引时完整下载到缓存目录,阅读时优先本地。省流量、体验好,但全新书首次打开有等待,且占用磁盘(默认 2GB 上限)。
|
|
||||||
- 选项 B:完全不落盘,每次按 Range/整文件从网盘拉。省磁盘,但每次打开都要重新下载,网盘限速时体验很差。
|
|
||||||
- 选项 C:折中——只对 EPUB/CBZ 缓存(解析必须读全文),漫画原图与 PDF 走流式。
|
|
||||||
|
|
||||||
我的建议是 **C**,因为它把「必须落盘」和「可以不落盘」分开了。
|
|
||||||
|
|
||||||
**② 章节正文的返回格式**
|
|
||||||
- JSON blocks(本方案推荐):安全、便于分页测量,但复杂 EPUB 排版(表格、脚注、竖排)会降级。
|
|
||||||
- 清洗后 HTML:保真度高,但前端要 `dangerouslySetInnerHTML`,XSS 面更大。
|
|
||||||
- 我的建议是 **JSON blocks 为主 + `?format=html` 回退**,两者都实现,前端在遇到 `type: "html-block"` 时回退渲染。
|
|
||||||
|
|
||||||
### 10.2 技术风险
|
|
||||||
|
|
||||||
| 风险 | 影响 | 缓解 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 自研分页内核的边界情况多(残页、跨章、RTL、竖排) | P1 可能超期 | P0 先只做滚动模式;分页单独立项,配套 `playerPageModel.test.ts` 那样的单测 |
|
|
||||||
| TXT 章节正则对网文变体覆盖不足 | 目录错乱 | 提供「手动重新切分」入口,规则可配(仿 `RecognitionWordsPanel` 的可配置词表模式) |
|
|
||||||
| 网盘直链失效 / 限速 / 防盗链 | 阅读中断 | 复用现有 115 换链与 `url_cache.go` 的缓存机制;失败时前端降级为「下载原文件」 |
|
|
||||||
| 大 TXT(>50MB)章节表行数过多 | SQLite 写入慢 | 章节超过阈值(如 5000 章)时按固定窗口粗切,或改为「按需切分 + 缓存到章节表」的惰性策略 |
|
|
||||||
| 缓存目录膨胀 | 磁盘打满 | 容量上限 + 复用 `service.PruneImageCache` 的 LRU 清理 + 系统设置页可见 |
|
|
||||||
| 数据库迁移对老库不兼容 | 升级失败 | 新表全部是纯新增,无列变更;不触碰 `ensurePostgresColumnCompatibility` 的既有语句 |
|
|
||||||
|
|
||||||
### 10.3 不引入的新依赖清单
|
|
||||||
|
|
||||||
| 依赖 | 用途 | 取舍 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `dompurify` | 前端 HTML 清洗兜底 | **建议引入**(前端必需) |
|
|
||||||
| `pdfjs-dist` | PDF 渲染 | **建议引入**(P1) |
|
|
||||||
| `github.com/nwaples/rardecode` | CBR 解压 | 可选;不接受则 CBR 首版只入库 |
|
|
||||||
| `epub.js` / `foliate-js` | EPUB 渲染 | **不引入**,见 6.3 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 测试策略
|
|
||||||
|
|
||||||
与项目现有测试密度对齐(`internal/service` 下大量 `_test.go`,前端有 `*.test.ts`):
|
|
||||||
|
|
||||||
**后端**
|
|
||||||
- `book_parser_test.go`:EPUB / TXT 各准备 fixture(`testdata/` 下小体积样本),断言元信息、章节数、章节边界字节偏移、GBK 转码正确性。
|
|
||||||
- `book_scanner_test.go`:临时目录扫描 + 重复扫描幂等 + 源文件删除后软删。
|
|
||||||
- `book_progress_test.go`:并发 upsert 不产生重复行(直接复现 `dedupePlaybackHistories` 防的那类 bug)。
|
|
||||||
- `book_permission_test.go`:越权矩阵,逐接口断言非可见书库返回 403/404。
|
|
||||||
- Handler 层:仿 `internal/handler/media_test.go` 起的 `httptest` + 真实内存 SQLite。
|
|
||||||
|
|
||||||
**前端**
|
|
||||||
- `readerModel.test.ts`:模式切换时的位置换算(`scroll ↔ paged`、`charOffset ↔ pageIndex`)、百分比计算、跨章边界。
|
|
||||||
- 分页计算的纯函数抽出单测(不含 DOM),参照 `web/src/pages/playerPageModel.test.ts` 的做法——把逻辑从组件里拔出来测,是项目已有的好传统。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 附:改动文件清单
|
|
||||||
|
|
||||||
**后端新增**
|
|
||||||
```
|
|
||||||
internal/model/book.go
|
|
||||||
internal/repository/book_repository.go
|
|
||||||
internal/service/book_parser.go EPUB / TXT / CBZ 解析
|
|
||||||
internal/service/book_parser_epub.go
|
|
||||||
internal/service/book_parser_txt.go
|
|
||||||
internal/service/book_parser_comic.go
|
|
||||||
internal/service/book_scanner.go
|
|
||||||
internal/service/book_reader.go 章节 / 页面 / 资源的读取与清洗
|
|
||||||
internal/service/book_progress.go
|
|
||||||
internal/service/book_stats.go
|
|
||||||
internal/service/book_cache.go 网盘缓存与 LRU
|
|
||||||
internal/handler/books.go
|
|
||||||
internal/handler/books_library.go
|
|
||||||
internal/handler/books_reader.go
|
|
||||||
internal/handler/routes_books.go
|
|
||||||
```
|
|
||||||
|
|
||||||
**后端修改**
|
|
||||||
```
|
|
||||||
internal/model/model.go AllModels() 追加 9 张表
|
|
||||||
internal/model/permission.go 4 个权限位
|
|
||||||
internal/model/user.go ReaderSettings / AllowedBookLibraryIDs
|
|
||||||
internal/repository/repository.go Container 加字段
|
|
||||||
internal/service/service.go Container 加字段 + Boot() 启动扫描
|
|
||||||
internal/handler/routes_authenticated.go 挂载 registerAuthedBookRoutes
|
|
||||||
internal/service/scheduler_local_jobs.go 书籍缓存清理 + 每日阅读统计汇总
|
|
||||||
internal/config/types.go CacheConfig 加 BooksMaxSizeMB;新增 BookConfig(扫描并发等)
|
|
||||||
docker-compose*.yml 书籍目录挂载注释
|
|
||||||
README.md / README_EN.md 能力表新增「阅读」
|
|
||||||
```
|
|
||||||
|
|
||||||
**前端新增**
|
|
||||||
```
|
|
||||||
web/src/api/books.ts
|
|
||||||
web/src/stores/readerSettings.ts
|
|
||||||
web/src/pages/BookshelfPage.tsx
|
|
||||||
web/src/pages/BookDetailPage.tsx
|
|
||||||
web/src/pages/BookReaderPage.tsx
|
|
||||||
web/src/pages/BookStatsPage.tsx
|
|
||||||
web/src/pages/BookLibraryPage.tsx
|
|
||||||
web/src/components/book/*.tsx
|
|
||||||
```
|
|
||||||
**前端修改**
|
|
||||||
```
|
|
||||||
web/src/appRoutes.tsx 4 条路由
|
|
||||||
web/src/components/layoutNavigation.ts 导航项、阅读器路由判定、返回链
|
|
||||||
web/src/types/auth.ts 权限位
|
|
||||||
web/src/stores/permissions.ts 权限位默认值 / 标签 / 分组
|
|
||||||
web/src/pages/HomePageSections.tsx 「继续阅读」区块
|
|
||||||
web/src/pages/settingsGroupBooks.ts (新增)阅读设置分组
|
|
||||||
web/src/pages/settingsGroups.ts 把 settingsGroupBooks 加入 GROUPS 数组
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 620 KiB |
|
Before Width: | Height: | Size: 743 KiB |
|
Before Width: | Height: | Size: 793 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 612 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -20,6 +20,12 @@ type settingReq struct {
|
|||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maskedSettingKeys 里的设置值绝不能被完整下发:它们是可用于对外操作的凭据。
|
||||||
|
// 下发脱敏值,保存时再靠 isMaskedSettingValue 还原为「保持原值」。
|
||||||
|
var maskedSettingKeys = map[string]bool{
|
||||||
|
service.SettingTelegramBotToken: true,
|
||||||
|
}
|
||||||
|
|
||||||
func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
settings, err := svc.Repo.Setting.All(c.Request.Context())
|
settings, err := svc.Repo.Setting.All(c.Request.Context())
|
||||||
@@ -27,10 +33,21 @@ func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
for i := range settings {
|
||||||
|
if maskedSettingKeys[settings[i].Key] {
|
||||||
|
settings[i].Value = service.MaskSecret(settings[i].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, settings)
|
c.JSON(http.StatusOK, settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isMaskedSettingValue 识别「前端把脱敏值原样提交回来」的情况。此时必须保留
|
||||||
|
// 已存的真实值,否则一次保存就会把凭据覆盖成 ***。
|
||||||
|
func isMaskedSettingValue(value string) bool {
|
||||||
|
return strings.Contains(value, "***")
|
||||||
|
}
|
||||||
|
|
||||||
func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req settingReq
|
var req settingReq
|
||||||
@@ -38,6 +55,11 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 脱敏值回传 == 用户没改这个凭据,保留库里已存的真实值。
|
||||||
|
if maskedSettingKeys[req.Key] && isMaskedSettingValue(req.Value) {
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
oldValue := ""
|
oldValue := ""
|
||||||
if req.Key == service.AdultLibraryIDsSettingKey {
|
if req.Key == service.AdultLibraryIDsSettingKey {
|
||||||
oldValue, _ = svc.Repo.Setting.Get(c.Request.Context(), req.Key)
|
oldValue, _ = svc.Repo.Setting.Get(c.Request.Context(), req.Key)
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 设备管理接口。
|
||||||
|
//
|
||||||
|
// 普通用户只能操作自己的设备(路由挂在 /me 下,用户 ID 始终取自会话);
|
||||||
|
// 管理员通过 /admin/users/:id/devices 代管任意用户。两组接口共用同一份
|
||||||
|
// DeviceService,因此「谁上线过、谁被踢掉」只有一处事实来源。
|
||||||
|
|
||||||
|
// deviceListPayload 是设备列表的下发形状。Fingerprint 不外发:它是防共享
|
||||||
|
// 判定用的内部标识,暴露出去只会方便伪造。
|
||||||
|
type deviceListPayload struct {
|
||||||
|
Devices []devicePayload `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type devicePayload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
DeviceName string `json:"device_name,omitempty"`
|
||||||
|
Client string `json:"client,omitempty"`
|
||||||
|
LastIP string `json:"last_ip,omitempty"`
|
||||||
|
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||||
|
LastPlayAt string `json:"last_play_at,omitempty"`
|
||||||
|
Kicked bool `json:"kicked"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Playing bool `json:"playing"`
|
||||||
|
Warnings int `json:"warnings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDevicePayload(d model.UserDevice) devicePayload {
|
||||||
|
out := devicePayload{
|
||||||
|
ID: d.ID,
|
||||||
|
DeviceID: d.DeviceID,
|
||||||
|
DeviceName: d.DeviceName,
|
||||||
|
Client: d.Client,
|
||||||
|
LastIP: d.LastIP,
|
||||||
|
Kicked: d.Kicked,
|
||||||
|
Online: d.Online,
|
||||||
|
Playing: d.Playing,
|
||||||
|
Warnings: d.Warnings,
|
||||||
|
}
|
||||||
|
if !d.LastSeenAt.IsZero() {
|
||||||
|
out.LastSeenAt = d.LastSeenAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
}
|
||||||
|
if d.LastPlayAt != nil && !d.LastPlayAt.IsZero() {
|
||||||
|
out.LastPlayAt = d.LastPlayAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceListResponse(devices []model.UserDevice) deviceListPayload {
|
||||||
|
items := make([]devicePayload, 0, len(devices))
|
||||||
|
for _, d := range devices {
|
||||||
|
items = append(items, toDevicePayload(d))
|
||||||
|
}
|
||||||
|
return deviceListPayload{Devices: items}
|
||||||
|
}
|
||||||
|
|
||||||
|
// myDevicesHandler 返回当前会话用户的设备列表。
|
||||||
|
func myDevicesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := sessionUserID(c)
|
||||||
|
devices, err := svc.Device.ListDevices(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, deviceListResponse(devices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// myKickDeviceHandler 踢掉当前用户的一台设备。
|
||||||
|
func myKickDeviceHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := sessionUserID(c)
|
||||||
|
deviceID := strings.TrimSpace(c.Param("deviceID"))
|
||||||
|
if deviceID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "device id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.Device.KickDevice(c.Request.Context(), userID, deviceID); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// myKickAllDevicesHandler 踢掉当前用户的全部设备。
|
||||||
|
func myKickAllDevicesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := sessionUserID(c)
|
||||||
|
if err := svc.Device.KickAllDevices(c.Request.Context(), userID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminUserDevicesHandler 返回指定用户的设备列表。
|
||||||
|
func adminUserDevicesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := strings.TrimSpace(c.Param("id"))
|
||||||
|
// FindByID 对「不存在」返回 (nil, nil),必须判空而不是判 error,
|
||||||
|
// 否则「用户不存在」会伪装成「该用户没有设备」的空列表。
|
||||||
|
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
devices, err := svc.Device.ListDevices(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, deviceListResponse(devices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminKickUserDeviceHandler 由管理员踢掉指定用户的一台设备。
|
||||||
|
func adminKickUserDeviceHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := strings.TrimSpace(c.Param("id"))
|
||||||
|
deviceID := strings.TrimSpace(c.Param("deviceID"))
|
||||||
|
if deviceID == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "device id required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.Device.KickDevice(c.Request.Context(), userID, deviceID); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminKickAllUserDevicesHandler 由管理员踢掉指定用户的全部设备。
|
||||||
|
func adminKickAllUserDevicesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := strings.TrimSpace(c.Param("id"))
|
||||||
|
if err := svc.Device.KickAllDevices(c.Request.Context(), userID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionUserID 读取会话用户 ID。调用方路由都挂在鉴权中间件之后,因此这里
|
||||||
|
// 只做类型断言兜底,不做权限判断。
|
||||||
|
func sessionUserID(c *gin.Context) string {
|
||||||
|
if v, ok := c.Get(middleware.CtxUserID); ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newDeviceTestEnv 搭一个只挂设备/Telegram 路由的最小环境,并预置两个用户,
|
||||||
|
// 用于验证「只能操作自己的设备」这条边界。
|
||||||
|
func newDeviceTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.User{}, &model.UserDevice{}, &model.Setting{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||||
|
svc.Device = service.NewDeviceService(zap.NewNop(), repos)
|
||||||
|
svc.Device.SetSessionTracker(service.NewSessionTrackerService(zap.NewNop()))
|
||||||
|
svc.Telegram = service.NewTelegramService(zap.NewNop(), repos)
|
||||||
|
|
||||||
|
const secret = "test-secret"
|
||||||
|
router := gin.New()
|
||||||
|
authed := router.Group("/api", func(c *gin.Context) {
|
||||||
|
// 测试里直接注入会话身份,绕开真实 JWT 解析。
|
||||||
|
if uid := c.GetHeader("X-Test-User"); uid != "" {
|
||||||
|
c.Set(middleware.CtxUserID, uid)
|
||||||
|
c.Set(middleware.CtxUserRole, c.GetHeader("X-Test-Role"))
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
authed.GET("/me/devices", myDevicesHandler(svc))
|
||||||
|
authed.POST("/me/devices/kick-all", myKickAllDevicesHandler(svc))
|
||||||
|
authed.POST("/me/devices/:deviceID/kick", myKickDeviceHandler(svc))
|
||||||
|
authed.GET("/me/telegram", getTelegramStatusHandler(svc))
|
||||||
|
authed.POST("/me/telegram/bind-code", startTelegramBindHandler(svc))
|
||||||
|
authed.DELETE("/me/telegram", unbindTelegramHandler(svc))
|
||||||
|
authed.GET("/admin/users/:id/devices", adminUserDevicesHandler(svc))
|
||||||
|
authed.POST("/admin/users/:id/devices/:deviceID/kick", adminKickUserDeviceHandler(svc))
|
||||||
|
_ = secret
|
||||||
|
return router, svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedDeviceUsers(t *testing.T, svc *service.Container) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, id := range []string{"user-a", "user-b"} {
|
||||||
|
if err := svc.Repo.User.Create(ctx, &model.User{
|
||||||
|
Base: model.Base{ID: id}, Username: id, PasswordHash: "x", Role: "user", IsActive: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func doJSON(t *testing.T, router *gin.Engine, method, path, userID, role string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, nil)
|
||||||
|
req.Header.Set("X-Test-User", userID)
|
||||||
|
if role != "" {
|
||||||
|
req.Header.Set("X-Test-Role", role)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// /me/devices 只能返回调用者自己的设备。
|
||||||
|
func TestMyDevicesScopedToCaller(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
seedDeviceUsers(t, svc)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
svc.Device.RecordLogin(ctx, "user-a", "dev-a", "A-Phone", "Infuse", "1.1.1.1")
|
||||||
|
svc.Device.RecordLogin(ctx, "user-b", "dev-b", "B-Phone", "Infuse", "2.2.2.2")
|
||||||
|
|
||||||
|
w := doJSON(t, router, http.MethodGet, "/api/me/devices", "user-a", "user")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Devices []struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
} `json:"devices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(payload.Devices) != 1 || payload.Devices[0].DeviceID != "dev-a" {
|
||||||
|
t.Fatalf("devices = %+v, want only dev-a", payload.Devices)
|
||||||
|
}
|
||||||
|
// 设备指纹属于内部判定标识,不能下发。
|
||||||
|
if strings.Contains(w.Body.String(), "fingerprint") {
|
||||||
|
t.Fatalf("response must not expose fingerprint: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 踢别人的设备必须失败:/me 路由用会话身份,deviceID 属于他人时查不到。
|
||||||
|
func TestKickForeignDeviceFails(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
seedDeviceUsers(t, svc)
|
||||||
|
svc.Device.RecordLogin(context.Background(), "user-b", "dev-b", "B-Phone", "Infuse", "2.2.2.2")
|
||||||
|
|
||||||
|
w := doJSON(t, router, http.MethodPost, "/api/me/devices/dev-b/kick", "user-a", "user")
|
||||||
|
if w.Code == http.StatusNoContent {
|
||||||
|
t.Fatal("user-a must not be able to kick user-b's device")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMyKickOwnDeviceSucceeds(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
seedDeviceUsers(t, svc)
|
||||||
|
svc.Device.RecordLogin(context.Background(), "user-a", "dev-a", "A-Phone", "Infuse", "1.1.1.1")
|
||||||
|
|
||||||
|
w := doJSON(t, router, http.MethodPost, "/api/me/devices/dev-a/kick", "user-a", "user")
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员接口对不存在的用户返回 404,避免把「用户不存在」和「用户没有设备」
|
||||||
|
// 混成同一个空列表。
|
||||||
|
func TestAdminDevicesUnknownUserReturns404(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
seedDeviceUsers(t, svc)
|
||||||
|
|
||||||
|
w := doJSON(t, router, http.MethodGet, "/api/admin/users/nope/devices", "admin-1", "admin")
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTelegramStatusAndBindCode(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
seedDeviceUsers(t, svc)
|
||||||
|
|
||||||
|
w := doJSON(t, router, http.MethodGet, "/api/me/telegram", "user-a", "user")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), `"bound":false`) {
|
||||||
|
t.Fatalf("body = %s, want bound=false", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
w = doJSON(t, router, http.MethodPost, "/api/me/telegram/bind-code", "user-a", "user")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var code struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
ExpiresIn int `json:"expires_in_seconds"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &code); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(code.Code) != 6 {
|
||||||
|
t.Fatalf("code = %q, want 6 chars", code.Code)
|
||||||
|
}
|
||||||
|
if code.ExpiresIn <= 0 {
|
||||||
|
t.Fatalf("expires_in_seconds = %d, want > 0", code.ExpiresIn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsMasksBotToken(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := svc.Repo.Setting.Set(ctx, service.SettingTelegramBotToken, "123456:AAHsecretTOKEN"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
router.GET("/api/admin/settings", listSettingsHandler(svc))
|
||||||
|
router.PUT("/api/admin/settings", updateSettingHandler(svc))
|
||||||
|
|
||||||
|
w := doJSON(t, router, http.MethodGet, "/api/admin/settings", "admin-1", "admin")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", w.Code)
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), "AAHsecretTOKEN") {
|
||||||
|
t.Fatalf("bot token leaked: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "***") {
|
||||||
|
t.Fatalf("bot token should be masked: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把脱敏值原样提交回来时,必须保留库里真实 Token —— 否则一次保存就把凭据毁掉。
|
||||||
|
func TestSavingMaskedTokenKeepsRealValue(t *testing.T) {
|
||||||
|
router, svc := newDeviceTestEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const real = "123456:AAHsecretTOKEN"
|
||||||
|
if err := svc.Repo.Setting.Set(ctx, service.SettingTelegramBotToken, real); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
router.PUT("/api/admin/settings", updateSettingHandler(svc))
|
||||||
|
|
||||||
|
body := `{"key":"telegram.bot_token","value":"12***EN"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Test-User", "admin-1")
|
||||||
|
req.Header.Set("X-Test-Role", "admin")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
got, err := svc.Repo.Setting.Get(ctx, service.SettingTelegramBotToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != real {
|
||||||
|
t.Fatalf("stored token = %q, want the original value preserved", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emby 发现类接口的 handler:NextUp / Similar / Genres。
|
||||||
|
//
|
||||||
|
// 这三个接口此前返回空列表,导致第三方客户端首页「接下来播放」、详情页
|
||||||
|
// 「相似推荐」、按类型浏览全部为空白。它们必须始终返回 200 + 合法信封,
|
||||||
|
// 因为客户端在首页刷新时会并发请求,任何 4xx/5xx 都会被判定为服务端异常。
|
||||||
|
|
||||||
|
func embyNextUpHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userID := embyScopedUserID(c)
|
||||||
|
if userID == "" {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
|
||||||
|
out, err := svc.Emby.NextUp(c.Request.Context(), userID, limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embySimilarHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
mediaID := strings.TrimSpace(c.Param("id"))
|
||||||
|
if mediaID == "" {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
|
||||||
|
out, err := svc.Emby.SimilarItems(c.Request.Context(), mediaID, embyEffectiveUserID(c), limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
embyAttachRequestTokenToMediaSources(c, out)
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyGenresHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid")
|
||||||
|
out, err := svc.Emby.Genres(c.Request.Context(), embyEffectiveUserID(c), parentID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// embyScopedUserID 解析「按用户请求」的 Emby 接口的生效用户。
|
||||||
|
//
|
||||||
|
// 路由上带 :userId 时(/Users/{uid}/Shows/NextUp),只允许查询自己:客户端
|
||||||
|
// 偶尔会带着别人的 id 请求,直接采信等于开放他人观看历史的读取。管理员同样
|
||||||
|
// 按自己处理,避免出现一条无人使用的越权路径。
|
||||||
|
func embyScopedUserID(c *gin.Context) string {
|
||||||
|
caller := embyEffectiveUserID(c)
|
||||||
|
requested := strings.TrimSpace(c.Param("userId"))
|
||||||
|
if requested == "" {
|
||||||
|
return caller
|
||||||
|
}
|
||||||
|
if requested == caller {
|
||||||
|
return caller
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// embyEmptyItemsPayload 与 embyEmptyItemsHandler 保持同一形状。
|
||||||
|
func embyEmptyItemsPayload() gin.H {
|
||||||
|
return gin.H{"Items": []any{}, "TotalRecordCount": 0}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newEmbyDiscoveryEnv 搭一个跑在内存库上的 Emby 路由环境。
|
||||||
|
func newEmbyDiscoveryEnv(t *testing.T) (*gin.Engine, *service.Container, string) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{},
|
||||||
|
&model.Setting{}, &model.Favorite{}, &model.UserDevice{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Secrets.JWTSecret = "test-secret"
|
||||||
|
|
||||||
|
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||||
|
svc.Emby = service.NewEmbyService(cfg, zap.NewNop(), repos).
|
||||||
|
SetDiscovery(service.NewMediaDiscoveryService(zap.NewNop(), repos))
|
||||||
|
|
||||||
|
const userID = "user-1"
|
||||||
|
if err := repos.User.Create(context.Background(), &model.User{
|
||||||
|
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x",
|
||||||
|
Role: "user", IsActive: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc)
|
||||||
|
return router, svc, userID
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedEmbyLibrary(t *testing.T, svc *service.Container, typ string) string {
|
||||||
|
t.Helper()
|
||||||
|
lib := &model.Library{Name: "库-" + typ, Path: "/media/" + typ, Type: typ, Enabled: true}
|
||||||
|
if err := svc.Repo.Library.Create(context.Background(), lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return lib.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyGet(t *testing.T, router *gin.Engine, path, token string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("X-Emby-Token", token)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeItemsEnvelope(t *testing.T, body []byte) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var payload struct {
|
||||||
|
Items []map[string]any `json:"Items"`
|
||||||
|
TotalRecordCount int64 `json:"TotalRecordCount"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", string(body), err)
|
||||||
|
}
|
||||||
|
return payload.Items
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextUp 必须真的返回下一集,而不是空数组。
|
||||||
|
func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
|
||||||
|
router, svc, userID := newEmbyDiscoveryEnv(t)
|
||||||
|
libID := seedEmbyLibrary(t, svc, "tv")
|
||||||
|
watchedAt := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
for episode, watched := range map[int]bool{1: true, 2: false, 3: false} {
|
||||||
|
m := &model.Media{
|
||||||
|
LibraryID: libID, SeriesID: "series-1", Title: "剧一",
|
||||||
|
SeasonNum: 1, EpisodeNum: episode,
|
||||||
|
Path: "/media/tv/S1E" + string(rune('0'+episode)) + ".mkv",
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if watched {
|
||||||
|
h := &model.PlaybackHistory{
|
||||||
|
UserID: userID, MediaID: m.ID, PositionMs: 1000, DurationMs: 2000,
|
||||||
|
WatchedAt: watchedAt, Completed: false,
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Shows/NextUp", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
|
||||||
|
}
|
||||||
|
if index, ok := items[0]["IndexNumber"].(float64); !ok || int(index) != 2 {
|
||||||
|
t.Fatalf("IndexNumber = %v, want 2 (body=%s)", items[0]["IndexNumber"], w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有历史时必须返回合法空信封,不能 404/500。
|
||||||
|
func TestEmbyNextUpEmptyWithoutHistory(t *testing.T) {
|
||||||
|
router, _, _ := newEmbyDiscoveryEnv(t)
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Shows/NextUp", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
|
||||||
|
t.Fatalf("items = %d, want 0", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小写别名路由同样要走到真实实现(客户端路径大小写并不统一)。
|
||||||
|
func TestEmbyNextUpLowercaseAlias(t *testing.T) {
|
||||||
|
router, svc, _ := newEmbyDiscoveryEnv(t)
|
||||||
|
_ = seedEmbyLibrary(t, svc, "tv")
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/shows/nextup", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similar 对不存在的条目返回空列表(客户端详情页会无条件请求)。
|
||||||
|
func TestEmbySimilarUnknownItemReturnsEmpty(t *testing.T) {
|
||||||
|
router, _, _ := newEmbyDiscoveryEnv(t)
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Items/does-not-exist/Similar", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
|
||||||
|
t.Fatalf("items = %d, want 0", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbySimilarReturnsCandidates(t *testing.T) {
|
||||||
|
router, svc, _ := newEmbyDiscoveryEnv(t)
|
||||||
|
libID := seedEmbyLibrary(t, svc, "movie")
|
||||||
|
|
||||||
|
source := &model.Media{
|
||||||
|
LibraryID: libID, Title: "源片", Genres: "Action", Year: 2010, Rating: 8,
|
||||||
|
Path: "/media/movie/source.mkv",
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(source).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other := &model.Media{
|
||||||
|
LibraryID: libID, Title: "同类片", Genres: "Action", Year: 2011, Rating: 8,
|
||||||
|
Path: "/media/movie/other.mkv",
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(other).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Items/"+source.ID+"/Similar", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
|
||||||
|
}
|
||||||
|
if name, _ := items[0]["Name"].(string); name != "同类片" {
|
||||||
|
t.Fatalf("Name = %q, want 同类片", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genres 必须返回真实类型与计数。
|
||||||
|
func TestEmbyGenresReturnsCounts(t *testing.T) {
|
||||||
|
router, svc, _ := newEmbyDiscoveryEnv(t)
|
||||||
|
libID := seedEmbyLibrary(t, svc, "movie")
|
||||||
|
|
||||||
|
for i, genres := range []string{"Action,Drama", "Action"} {
|
||||||
|
m := &model.Media{
|
||||||
|
LibraryID: libID, Title: "片" + string(rune('A'+i)), Genres: genres,
|
||||||
|
Path: "/media/movie/m" + string(rune('0'+i)) + ".mkv",
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Genres", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("items = %d, want 2 (body=%s)", len(items), w.Body.String())
|
||||||
|
}
|
||||||
|
// 排序按计数降序:Action(2) 在前。
|
||||||
|
if name, _ := items[0]["Name"].(string); name != "Action" {
|
||||||
|
t.Fatalf("first Name = %q, want Action", name)
|
||||||
|
}
|
||||||
|
if count, ok := items[0]["ItemCount"].(float64); !ok || int(count) != 2 {
|
||||||
|
t.Fatalf("ItemCount = %v, want 2", items[0]["ItemCount"])
|
||||||
|
}
|
||||||
|
if id, _ := items[0]["Id"].(string); len(id) == 0 {
|
||||||
|
t.Fatal("genre item must carry a stable Id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按别人的 userId 请求 NextUp 不允许泄露他人历史。
|
||||||
|
func TestEmbyNextUpRejectsForeignUserID(t *testing.T) {
|
||||||
|
router, _, _ := newEmbyDiscoveryEnv(t)
|
||||||
|
|
||||||
|
w := embyGet(t, router, "/emby/Users/someone-else/Shows/NextUp", signedTestToken(t, "test-secret"))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
|
||||||
|
t.Fatalf("items = %d, want 0", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,15 +196,15 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
|
|||||||
auth.GET("/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
auth.GET("/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
||||||
auth.GET("/Users/:userId/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
auth.GET("/Users/:userId/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
||||||
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
||||||
auth.GET("/Shows/NextUp", embyEmptyItemsHandler(svc))
|
auth.GET("/Shows/NextUp", embyNextUpHandler(svc))
|
||||||
auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc))
|
auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc))
|
||||||
auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc))
|
auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/Artists", embyEmptyItemsHandler(svc))
|
auth.GET("/Artists", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/Persons", embyEmptyItemsHandler(svc))
|
auth.GET("/Persons", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/Genres", embyEmptyItemsHandler(svc))
|
auth.GET("/Genres", embyGenresHandler(svc))
|
||||||
auth.GET("/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
auth.GET("/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/Users/:userId/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
auth.GET("/Users/:userId/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/Items/:id/Similar", embyEmptyItemsHandler(svc))
|
auth.GET("/Items/:id/Similar", embySimilarHandler(svc))
|
||||||
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
|
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
|
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
|
||||||
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
|
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
|
||||||
|
|||||||
@@ -40,15 +40,15 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
|
|||||||
auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
||||||
auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
||||||
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
||||||
auth.GET("/shows/nextup", embyEmptyItemsHandler(svc))
|
auth.GET("/shows/nextup", embyNextUpHandler(svc))
|
||||||
auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc))
|
auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc))
|
||||||
auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc))
|
auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/artists", embyEmptyItemsHandler(svc))
|
auth.GET("/artists", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/persons", embyEmptyItemsHandler(svc))
|
auth.GET("/persons", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/genres", embyEmptyItemsHandler(svc))
|
auth.GET("/genres", embyGenresHandler(svc))
|
||||||
auth.GET("/shows/upcoming", embyEmptyItemsHandler(svc))
|
auth.GET("/shows/upcoming", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/users/:userId/shows/upcoming", embyEmptyItemsHandler(svc))
|
auth.GET("/users/:userId/shows/upcoming", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/items/:id/similar", embyEmptyItemsHandler(svc))
|
auth.GET("/items/:id/similar", embySimilarHandler(svc))
|
||||||
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
|
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
|
||||||
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
|
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
|
||||||
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
|
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 媒体库筛选面板接口:facets 提供可选项,random 提供「随便看看」。
|
||||||
|
//
|
||||||
|
// 两者都走与列表完全相同的可见性判定(mediaVisibilityForRequest)与筛选解析
|
||||||
|
// (parseLibraryFilters),因此不会出现「列表里有、facets 里没有」或「随机跳
|
||||||
|
// 到了筛选条件之外的条目」这类不一致。
|
||||||
|
|
||||||
|
func libraryFacetsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
libraryID := c.Param("id")
|
||||||
|
facets, err := svc.Media.LibraryFacets(
|
||||||
|
c.Request.Context(),
|
||||||
|
libraryID,
|
||||||
|
mediaVisibilityForRequest(c, svc),
|
||||||
|
svc.Discovery,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalOrCanceled(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, facets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func libraryRandomHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
libraryID := c.Param("id")
|
||||||
|
filters := parseLibraryFilters(c)
|
||||||
|
// 随机只取一条,因此不带分页参数;未观看筛选仍需要会话用户。
|
||||||
|
media, err := svc.Media.RandomMedia(
|
||||||
|
c.Request.Context(),
|
||||||
|
libraryID,
|
||||||
|
mediaVisibilityForRequest(c, svc),
|
||||||
|
filters,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalOrCanceled(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if media == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no media matches the current filters"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, media)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLibraryFilterEnv(t *testing.T) (*gin.Engine, *service.Container, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{}, &model.Setting{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||||
|
svc.Media = service.NewMediaService(nil, zap.NewNop(), repos)
|
||||||
|
svc.Discovery = service.NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
|
||||||
|
const userID = "user-1"
|
||||||
|
if err := repos.User.Create(context.Background(), &model.User{
|
||||||
|
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x", Role: "user", IsActive: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lib := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||||
|
if err := repos.Library.Create(context.Background(), lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
authed := router.Group("/api", func(c *gin.Context) {
|
||||||
|
c.Set(middleware.CtxUserID, userID)
|
||||||
|
c.Set(middleware.CtxUserRole, "user")
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
authed.GET("/libraries/:id/media", listMediaHandler(svc))
|
||||||
|
authed.GET("/libraries/:id/facets", libraryFacetsHandler(svc))
|
||||||
|
authed.GET("/libraries/:id/random", libraryRandomHandler(svc))
|
||||||
|
return router, svc, userID, lib.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedLibraryMedia(t *testing.T, svc *service.Container, rows ...*model.Media) {
|
||||||
|
t.Helper()
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := svc.Repo.DB.Create(row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getJSON(t *testing.T, router *gin.Engine, path string) (int, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
return w.Code, w.Body.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryFacetsReturnGenresAndYears(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "A", Genres: "Action,Drama", Year: 1999, Path: "/a.mkv"},
|
||||||
|
&model.Media{LibraryID: libID, Title: "B", Genres: "Action", Year: 2021, Path: "/b.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/facets")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var facets struct {
|
||||||
|
Genres []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
} `json:"genres"`
|
||||||
|
YearMin int `json:"year_min"`
|
||||||
|
YearMax int `json:"year_max"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &facets); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", body, err)
|
||||||
|
}
|
||||||
|
if facets.YearMin != 1999 || facets.YearMax != 2021 {
|
||||||
|
t.Fatalf("year range = %d..%d, want 1999..2021", facets.YearMin, facets.YearMax)
|
||||||
|
}
|
||||||
|
if len(facets.Genres) != 2 {
|
||||||
|
t.Fatalf("genres = %+v, want 2 entries", facets.Genres)
|
||||||
|
}
|
||||||
|
if facets.Genres[0].Name != "Action" || facets.Genres[0].Count != 2 {
|
||||||
|
t.Fatalf("first genre = %+v, want Action:2", facets.Genres[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空库时 facets 必须返回空数组而不是 null,前端无需额外判空。
|
||||||
|
func TestLibraryFacetsEmptyLibrary(t *testing.T) {
|
||||||
|
router, _, _, libID := newLibraryFilterEnv(t)
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/facets")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
if !containsSubstring(string(body), `"genres":[]`) {
|
||||||
|
t.Fatalf("body = %s, want genres:[]", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表筛选:按类型过滤后只返回命中的条目。
|
||||||
|
func TestListMediaAppliesGenreFilter(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
|
||||||
|
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre=Action")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Items []model.Media `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", body, err)
|
||||||
|
}
|
||||||
|
if payload.Total != 1 || len(payload.Items) != 1 || payload.Items[0].Title != "动作" {
|
||||||
|
t.Fatalf("payload = %+v, want only 动作", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选条件必须进入缓存键:先请求未筛选列表、再筛选时不能命中旧缓存。
|
||||||
|
func TestListMediaFilterBypassesUnfilteredCache(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
|
||||||
|
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 先拉全量(可能写缓存),再拉筛选结果。
|
||||||
|
if code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0"); code != http.StatusOK {
|
||||||
|
t.Fatalf("unfiltered status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre=Comedy")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("filtered status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload.Total != 1 {
|
||||||
|
t.Fatalf("total = %d, want 1 (filtered response must not be served from the unfiltered cache)", payload.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未观看筛选:已看完的不出现,看了一半的仍出现。
|
||||||
|
func TestListMediaUnwatchedFilter(t *testing.T) {
|
||||||
|
router, svc, userID, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{Base: model.Base{ID: "m-done"}, LibraryID: libID, Title: "看完", Path: "/a.mkv"},
|
||||||
|
&model.Media{Base: model.Base{ID: "m-half"}, LibraryID: libID, Title: "看一半", Path: "/b.mkv"},
|
||||||
|
)
|
||||||
|
for _, h := range []*model.PlaybackHistory{
|
||||||
|
{UserID: userID, MediaID: "m-done", Completed: true},
|
||||||
|
{UserID: userID, MediaID: "m-half", Completed: false},
|
||||||
|
} {
|
||||||
|
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&unwatched=1")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Items []model.Media `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(payload.Items) != 1 || payload.Items[0].Title != "看一半" {
|
||||||
|
t.Fatalf("items = %+v, want only 看一半", payload.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryRandomReturnsMedia(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "唯一", Genres: "Action", Path: "/a.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var media model.Media
|
||||||
|
if err := json.Unmarshal(body, &media); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", body, err)
|
||||||
|
}
|
||||||
|
if media.Title != "唯一" {
|
||||||
|
t.Fatalf("title = %q, want 唯一", media.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选后没有命中时返回 404,前端据此提示「没有符合条件的媒体」。
|
||||||
|
func TestLibraryRandomEmptyResultIs404(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random?genre=Nonexistent")
|
||||||
|
if code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d body=%s, want 404", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// axios 默认把数组序列化为 genre[]=Action 格式;后端必须把它当作 genre=Action 处理。
|
||||||
|
func TestListMediaAcceptsBracketGenreParam(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
|
||||||
|
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// genre[]=Action — axios bracket format without custom paramsSerializer
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre[]=Action")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Items []model.Media `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", body, err)
|
||||||
|
}
|
||||||
|
if payload.Total != 1 || len(payload.Items) != 1 || payload.Items[0].Title != "动作" {
|
||||||
|
t.Fatalf("payload = %+v, want only 动作 for genre[]=Action", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机也遵守筛选:只命中 Action 时,带 Comedy 筛选必须 404。
|
||||||
|
func TestLibraryRandomHonoursFilters(t *testing.T) {
|
||||||
|
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||||
|
seedLibraryMedia(t, svc,
|
||||||
|
&model.Media{LibraryID: libID, Title: "A", Genres: "Action", Year: 2001, Path: "/a.mkv"},
|
||||||
|
&model.Media{LibraryID: libID, Title: "B", Genres: "Comedy", Year: 2002, Path: "/b.mkv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random?genre=Comedy&year_min=2002")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", code, body)
|
||||||
|
}
|
||||||
|
var media model.Media
|
||||||
|
if err := json.Unmarshal(body, &media); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.Title != "B" {
|
||||||
|
t.Fatalf("title = %q, want B", media.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -407,6 +408,92 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseLibraryFilters 解析媒体库列表的筛选查询参数。
|
||||||
|
//
|
||||||
|
// 全部参数都是可选的:缺省时返回零值,`MediaListFilters.empty()` 为真,列表
|
||||||
|
// 行为与此前完全一致(不引入任何默认筛选)。
|
||||||
|
//
|
||||||
|
// 参数约定:
|
||||||
|
// - genre=Action&genre=Comedy 类型多选(或关系,整词匹配)
|
||||||
|
// - year_min / year_max 年份区间,0 或非法值表示不限
|
||||||
|
// - rating_min 评分下限(浮点)
|
||||||
|
// - unwatched=1 仅显示未看完;用户 ID 取自会话
|
||||||
|
func parseLibraryFilters(c *gin.Context) service.MediaListFilters {
|
||||||
|
filters := service.MediaListFilters{
|
||||||
|
Genres: parseRepeatedQueryValues(c, "genre"),
|
||||||
|
YearMin: parseNonNegativeInt(firstQueryValue(c, "year_min", "yearMin")),
|
||||||
|
YearMax: parseNonNegativeInt(firstQueryValue(c, "year_max", "yearMax")),
|
||||||
|
RatingMin: parseNonNegativeFloat(firstQueryValue(c, "rating_min", "ratingMin")),
|
||||||
|
}
|
||||||
|
if isTruthyQuery(firstQueryValue(c, "unwatched", "unwatched_only", "unwatchedOnly")) {
|
||||||
|
filters.Unwatched = true
|
||||||
|
filters.UserID = toString(mustSessionUserID(c))
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRepeatedQueryValues 读取可重复出现的查询参数,去重并丢弃空值。
|
||||||
|
// 同时接受 key[] 括号格式(axios 1.x 默认序列化方式)作为向后兼容回退,
|
||||||
|
// 在前端 paramsSerializer 未正确配置时不会静默返回空结果。
|
||||||
|
func parseRepeatedQueryValues(c *gin.Context, key string) []string {
|
||||||
|
raw := c.QueryArray(key)
|
||||||
|
if len(raw) == 0 {
|
||||||
|
// fallback: axios bracket format (e.g. genre[]=Action&genre[]=Comedy)
|
||||||
|
raw = c.QueryArray(key + "[]")
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(raw))
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, value := range raw {
|
||||||
|
// 客户端可能把多值拼成一次逗号分隔,两种形式都要接受。
|
||||||
|
for _, part := range strings.Split(value, ",") {
|
||||||
|
trimmed := strings.TrimSpace(part)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[trimmed]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[trimmed] = struct{}{}
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNonNegativeInt(raw string) int {
|
||||||
|
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil || value < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNonNegativeFloat(raw string) float64 {
|
||||||
|
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||||
|
if err != nil || value < 0 || math.IsNaN(value) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTruthyQuery(raw string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustSessionUserID 取会话用户 ID,缺失时返回空串(筛选逻辑会忽略它)。
|
||||||
|
func mustSessionUserID(c *gin.Context) any {
|
||||||
|
uid, _ := c.Get(middleware.CtxUserID)
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
|
||||||
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
@@ -450,9 +537,10 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if sortSpec.Field == "last_played" {
|
if sortSpec.Field == "last_played" {
|
||||||
history = mediaHistoryMap(c, svc)
|
history = mediaHistoryMap(c, svc)
|
||||||
}
|
}
|
||||||
|
filters := parseLibraryFilters(c)
|
||||||
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
|
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
|
||||||
if !groupVersions {
|
if !groupVersions {
|
||||||
items, total, err := svc.Media.ListMediaVisible(ctx, id, page, size, mediaVisibilityForRequest(c, svc))
|
items, total, err := svc.Media.ListMediaVisibleFiltered(ctx, id, page, size, mediaVisibilityForRequest(c, svc), filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
@@ -468,7 +556,7 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
grouped, err := svc.Media.GroupedMediaVisible(ctx, id, mediaVisibilityForRequest(c, svc))
|
grouped, err := svc.Media.GroupedMediaVisibleFiltered(ctx, id, mediaVisibilityForRequest(c, svc), filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -116,8 +116,13 @@ func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
|||||||
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
|
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
|
||||||
admin.PATCH("/users/:id/libraries", updateUserLibrariesHandler(svc))
|
admin.PATCH("/users/:id/libraries", updateUserLibrariesHandler(svc))
|
||||||
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
||||||
|
// 设备代管:管理员可查看并踢掉任意用户的设备。
|
||||||
|
admin.GET("/users/:id/devices", adminUserDevicesHandler(svc))
|
||||||
|
admin.POST("/users/:id/devices/kick-all", adminKickAllUserDevicesHandler(svc))
|
||||||
|
admin.POST("/users/:id/devices/:deviceID/kick", adminKickUserDeviceHandler(svc))
|
||||||
admin.GET("/settings", listSettingsHandler(svc))
|
admin.GET("/settings", listSettingsHandler(svc))
|
||||||
admin.PUT("/settings", updateSettingHandler(svc))
|
admin.PUT("/settings", updateSettingHandler(svc))
|
||||||
|
admin.POST("/telegram/test", testTelegramHandler(svc))
|
||||||
admin.POST("/adult/test-scraper", testAdultScraperHandler(svc))
|
admin.POST("/adult/test-scraper", testAdultScraperHandler(svc))
|
||||||
admin.GET("/logs", recentLogsHandler(svc))
|
admin.GET("/logs", recentLogsHandler(svc))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
|
|||||||
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
|
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||||
authed.POST("/me/temporary-password", temporaryPasswordHandler(svc))
|
authed.POST("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||||
|
|
||||||
|
// 设备管理:路由挂在 /me 下,用户 ID 一律取自会话,天然只能管自己的设备。
|
||||||
|
authed.GET("/me/devices", myDevicesHandler(svc))
|
||||||
|
authed.POST("/me/devices/kick-all", myKickAllDevicesHandler(svc))
|
||||||
|
authed.POST("/me/devices/:deviceID/kick", myKickDeviceHandler(svc))
|
||||||
|
|
||||||
|
// Telegram 通知绑定:一次性码 + Bot /bind <code>。
|
||||||
|
authed.GET("/me/telegram", getTelegramStatusHandler(svc))
|
||||||
|
authed.POST("/me/telegram/bind-code", startTelegramBindHandler(svc))
|
||||||
|
authed.DELETE("/me/telegram", unbindTelegramHandler(svc))
|
||||||
|
|
||||||
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
|
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +48,8 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
|||||||
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
|
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
|
||||||
|
|
||||||
authed.GET("/libraries/:id/media", listMediaHandler(svc))
|
authed.GET("/libraries/:id/media", listMediaHandler(svc))
|
||||||
|
authed.GET("/libraries/:id/facets", libraryFacetsHandler(svc))
|
||||||
|
authed.GET("/libraries/:id/random", libraryRandomHandler(svc))
|
||||||
authed.GET("/libraries/:id/series", listLibrarySeriesHandler(svc))
|
authed.GET("/libraries/:id/series", listLibrarySeriesHandler(svc))
|
||||||
authed.GET("/libraries/:id/series/episodes", listLibrarySeriesEpisodesHandler(svc))
|
authed.GET("/libraries/:id/series/episodes", listLibrarySeriesEpisodesHandler(svc))
|
||||||
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
|
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
|
|||||||
authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(svc))
|
authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(svc))
|
||||||
|
|
||||||
authed.GET("/watch-history", historyListHandler(svc))
|
authed.GET("/watch-history", historyListHandler(svc))
|
||||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
authed.GET("/watch-history/stats", requirePermission(svc, "can_view_history"), historyStatsHandler(svc))
|
||||||
authed.GET("/watch-history/continue", historyContinueHandler(svc))
|
authed.GET("/watch-history/continue", historyContinueHandler(svc))
|
||||||
authed.DELETE("/watch-history", historyDeleteHandler(svc))
|
authed.DELETE("/watch-history", historyDeleteHandler(svc))
|
||||||
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
|
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items, total, err := svc.Media.ListLibrarySeriesCards(c.Request.Context(), libID, mediaVisibilityForRequest(c, svc))
|
items, total, err := svc.Media.ListLibrarySeriesCardsFiltered(
|
||||||
|
c.Request.Context(), libID, mediaVisibilityForRequest(c, svc), parseLibraryFilters(c),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeInternalOrCanceled(c, err)
|
writeInternalOrCanceled(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Telegram 绑定与测试接口。
|
||||||
|
//
|
||||||
|
// 绑定刻意做成「网页生成一次性码 → 用户在 Bot 里发 /bind <码>」:服务端不需要
|
||||||
|
// 用户手工填写 chat id,也不需要站点暴露 Bot 命令以外任何能力。
|
||||||
|
|
||||||
|
type telegramBindCodePayload struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
ExpiresIn int `json:"expires_in_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTelegramBindHandler 生成一次性绑定码。同一用户重复调用时旧码作废。
|
||||||
|
func startTelegramBindHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if svc.Telegram == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "telegram service unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := sessionUserID(c)
|
||||||
|
code, err := svc.Telegram.StartBind(c.Request.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, telegramBindCodePayload{Code: code, ExpiresIn: 300})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTelegramStatusHandler 返回绑定状态与脱敏会话 ID。
|
||||||
|
func getTelegramStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if svc.Telegram == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"bound": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bound, masked := svc.Telegram.Status(c.Request.Context(), sessionUserID(c))
|
||||||
|
payload := gin.H{"bound": bound}
|
||||||
|
if bound {
|
||||||
|
payload["chat_id_masked"] = masked
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unbindTelegramHandler 解除当前用户的 Telegram 绑定。
|
||||||
|
func unbindTelegramHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if svc.Telegram == nil {
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.Telegram.Unbind(c.Request.Context(), sessionUserID(c)); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testTelegramHandler 向管理员会话发送一条测试消息,用于验证 Token/会话 ID。
|
||||||
|
func testTelegramHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if svc.Telegram == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "telegram service unavailable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !svc.Telegram.Configured(c.Request.Context()) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "请先启用 Telegram 通知并填写 Bot Token 与管理员 Chat ID",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.Telegram.SendToAdminChecked(c.Request.Context(), "✅ MeBox 测试消息:通知通道工作正常。"); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,11 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -48,11 +51,16 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// historyStatsHandler returns aggregate watch time + completion counts
|
// historyStatsHandler returns aggregate watch time + completion counts
|
||||||
// for the caller. Used by the WatchHistoryPage hero card.
|
// for the caller. Used by the WatchHistoryPage hero card and the dedicated
|
||||||
|
// personal statistics page.
|
||||||
|
//
|
||||||
|
// 统计口径全部来自 PlaybackHistory 本身,不新增统计表:position_ms 是「已看
|
||||||
|
// 时长」的近似值,足以支撑趋势图;精确到秒的播放时长另有会话统计负责。
|
||||||
func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
|
func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid, _ := c.Get(middleware.CtxUserID)
|
uid, _ := c.Get(middleware.CtxUserID)
|
||||||
userID := toString(uid)
|
userID := toString(uid)
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
|
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
|
||||||
@@ -77,16 +85,196 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
last = &lastT
|
last = &lastT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
visibility := mediaVisibilityForRequest(c, svc)
|
||||||
|
daily, byType, recent := historyStatsBreakdowns(ctx, svc, userID, visibility)
|
||||||
|
|
||||||
|
inProgress := total - completed
|
||||||
|
if inProgress < 0 {
|
||||||
|
inProgress = 0
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"total": total,
|
"total": total,
|
||||||
"completed": completed,
|
"completed": completed,
|
||||||
"watched_ms": watchedMs,
|
"in_progress": inProgress,
|
||||||
"watched_hours": float64(watchedMs) / 1000.0 / 3600.0,
|
"watched_ms": watchedMs,
|
||||||
"last_watched": last,
|
"watched_hours": float64(watchedMs) / 1000.0 / 3600.0,
|
||||||
|
"last_watched": last,
|
||||||
|
"daily": daily,
|
||||||
|
"by_library_type": byType,
|
||||||
|
"recent": recent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// historyStatsDailyDays 是趋势图回看的天数。
|
||||||
|
const historyStatsDailyDays = 30
|
||||||
|
|
||||||
|
// historyStatsRecentLimit 是「最近看过」返回的条数。
|
||||||
|
const historyStatsRecentLimit = 8
|
||||||
|
|
||||||
|
type historyDailyStat struct {
|
||||||
|
Day string `json:"day"`
|
||||||
|
WatchMs int64 `json:"watch_ms"`
|
||||||
|
Plays int64 `json:"plays"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type historyTypeStat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
WatchMs int64 `json:"watch_ms"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyStatsBreakdowns 产出每日趋势、按媒体库类型分布与最近记录。
|
||||||
|
//
|
||||||
|
// 分桶在 Go 里做而不是用 SQL 的日期函数:SQLite 的 strftime 与 PostgreSQL 的
|
||||||
|
// to_char 语法不同,写两份 SQL 会在方言差异上长期出错,而历史行数受用户规模
|
||||||
|
// 约束(每人一行一部媒体),一次全量读取是可以接受的。
|
||||||
|
//
|
||||||
|
// visibility 控制哪些媒体对调用者可见(播放档案、成人锁等)。
|
||||||
|
func historyStatsBreakdowns(ctx context.Context, svc *service.Container, userID string, visibility service.MediaVisibility) ([]historyDailyStat, []historyTypeStat, []map[string]any) {
|
||||||
|
daily := make([]historyDailyStat, 0, historyStatsDailyDays)
|
||||||
|
byType := make([]historyTypeStat, 0)
|
||||||
|
recent := make([]map[string]any, 0, historyStatsRecentLimit)
|
||||||
|
|
||||||
|
var rows []model.PlaybackHistory
|
||||||
|
if err := svc.Repo.DB.WithContext(ctx).
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Order("watched_at desc").
|
||||||
|
Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||||
|
return daily, byType, recent
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每日趋势:只回看最近 N 天,且按「本地日」分桶,避免跨时区偏移。
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.AddDate(0, 0, -(historyStatsDailyDays - 1))
|
||||||
|
startOfDay := func(t time.Time) time.Time {
|
||||||
|
local := t.In(time.Local)
|
||||||
|
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local)
|
||||||
|
}
|
||||||
|
buckets := make(map[string]*historyDailyStat, historyStatsDailyDays)
|
||||||
|
for i := 0; i < historyStatsDailyDays; i++ {
|
||||||
|
day := startOfDay(cutoff).AddDate(0, 0, i).Format("2006-01-02")
|
||||||
|
buckets[day] = &historyDailyStat{Day: day}
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
watched := r.WatchedAt.In(time.Local)
|
||||||
|
if watched.Before(startOfDay(cutoff)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bucket, ok := buckets[watched.Format("2006-01-02")]; ok {
|
||||||
|
bucket.WatchMs += r.PositionMs
|
||||||
|
bucket.Plays++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < historyStatsDailyDays; i++ {
|
||||||
|
day := startOfDay(cutoff).AddDate(0, 0, i).Format("2006-01-02")
|
||||||
|
if bucket, ok := buckets[day]; ok && bucket.Plays > 0 {
|
||||||
|
daily = append(daily, *bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaIDs := make([]string, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
mediaIDs = append(mediaIDs, r.MediaID)
|
||||||
|
}
|
||||||
|
var medias []model.Media
|
||||||
|
_ = svc.Repo.DB.WithContext(ctx).Where("id IN ?", mediaIDs).Find(&medias).Error
|
||||||
|
mediaByID := make(map[string]*model.Media, len(medias))
|
||||||
|
for i := range medias {
|
||||||
|
mediaByID[medias[i].ID] = &medias[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryTypes := make(map[string]string)
|
||||||
|
var libraries []model.Library
|
||||||
|
if svc.Repo.Library != nil {
|
||||||
|
if libs, err := svc.Repo.Library.List(ctx); err == nil {
|
||||||
|
libraries = libs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, lib := range libraries {
|
||||||
|
libraryTypes[lib.ID] = lib.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
typeAcc := make(map[string]*historyTypeStat)
|
||||||
|
order := make([]string, 0, 4)
|
||||||
|
for _, r := range rows {
|
||||||
|
media := mediaByID[r.MediaID]
|
||||||
|
var key string
|
||||||
|
if media == nil {
|
||||||
|
// 媒体记录已删除(含 Emby 远程缓存失效):计入 "other" 桶而非丢弃,
|
||||||
|
// 这样类型分布总数才能与播放历史总数吻合。
|
||||||
|
key = "other"
|
||||||
|
} else {
|
||||||
|
// 如果调用者的可见性策略排除了该媒体,则跳过统计(visibility leak fix)。
|
||||||
|
if !visibility.Allows(media) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(libraryTypes[media.LibraryID])
|
||||||
|
if key == "" {
|
||||||
|
key = "other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acc, ok := typeAcc[key]
|
||||||
|
if !ok {
|
||||||
|
acc = &historyTypeStat{Type: key}
|
||||||
|
typeAcc[key] = acc
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
acc.WatchMs += r.PositionMs
|
||||||
|
acc.Count++
|
||||||
|
}
|
||||||
|
// 顺序按观看时长降序,让「我主要在看什么」一眼可见。
|
||||||
|
for _, key := range order {
|
||||||
|
byType = append(byType, *typeAcc[key])
|
||||||
|
}
|
||||||
|
sort.SliceStable(byType, func(i, j int) bool {
|
||||||
|
if byType[i].WatchMs != byType[j].WatchMs {
|
||||||
|
return byType[i].WatchMs > byType[j].WatchMs
|
||||||
|
}
|
||||||
|
return byType[i].Type < byType[j].Type
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, r := range rows {
|
||||||
|
if len(recent) >= historyStatsRecentLimit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
entry := map[string]any{"history": r}
|
||||||
|
if media := mediaByID[r.MediaID]; media != nil {
|
||||||
|
// 可见性检查:隐藏库或受档案限制的媒体不进入最近记录(visibility leak fix)。
|
||||||
|
if !visibility.Allows(media) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry["media"] = media
|
||||||
|
} else if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
|
||||||
|
// 尝试从 Emby 远端补全媒体详情,与 historyContinueHandler 保持相同策略。
|
||||||
|
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
|
||||||
|
mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(ctx, mountID)
|
||||||
|
if resolveErr == nil && mount != nil && acct != nil {
|
||||||
|
remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
|
||||||
|
if detailErr == nil && remoteMedia != nil {
|
||||||
|
if !visibility.Allows(remoteMedia) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry["media"] = *remoteMedia
|
||||||
|
} else {
|
||||||
|
// 无法获取 Emby 媒体详情,跳过此条记录。
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 挂载不可用,跳过。
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 媒体记录不存在且无法 Emby 补全,跳过。
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recent = append(recent, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return daily, byType, recent
|
||||||
|
}
|
||||||
|
|
||||||
// historyContinueHandler returns "Continue Watching" rows: incomplete
|
// historyContinueHandler returns "Continue Watching" rows: incomplete
|
||||||
// items, most recent first.
|
// items, most recent first.
|
||||||
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newHistoryStatsEnv(t *testing.T) (*gin.Engine, *service.Container, string) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{}, &model.UserPermission{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||||
|
svc.Permissions = service.NewPermissionService(zap.NewNop(), repos)
|
||||||
|
|
||||||
|
const userID = "user-1"
|
||||||
|
if err := repos.User.Create(context.Background(), &model.User{
|
||||||
|
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x", Role: "user", IsActive: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
authed := router.Group("/api", func(c *gin.Context) {
|
||||||
|
c.Set(middleware.CtxUserID, userID)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||||
|
return router, svc, userID
|
||||||
|
}
|
||||||
|
|
||||||
|
type historyStatsPayload struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Completed int64 `json:"completed"`
|
||||||
|
InProgress int64 `json:"in_progress"`
|
||||||
|
WatchedMs int64 `json:"watched_ms"`
|
||||||
|
WatchedHours float64 `json:"watched_hours"`
|
||||||
|
Daily []struct {
|
||||||
|
Day string `json:"day"`
|
||||||
|
WatchMs int64 `json:"watch_ms"`
|
||||||
|
Plays int64 `json:"plays"`
|
||||||
|
} `json:"daily"`
|
||||||
|
ByLibraryType []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
WatchMs int64 `json:"watch_ms"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
} `json:"by_library_type"`
|
||||||
|
Recent []struct {
|
||||||
|
Media *model.Media `json:"media"`
|
||||||
|
} `json:"recent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchHistoryStats(t *testing.T, router *gin.Engine) historyStatsPayload {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/watch-history/stats", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var payload historyStatsPayload
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode %s: %v", w.Body.String(), err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新字段必须提供每日聚合、库类型分布与在看数量,供个人统计页绘图。
|
||||||
|
func TestHistoryStatsIncludesDailyAndTypes(t *testing.T) {
|
||||||
|
router, svc, userID := newHistoryStatsEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
movieLib := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||||
|
if err := svc.Repo.Library.Create(ctx, movieLib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tvLib := &model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
|
||||||
|
if err := svc.Repo.Library.Create(ctx, tvLib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
yesterday := time.Now().Add(-24 * time.Hour)
|
||||||
|
today := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
rows := []struct {
|
||||||
|
media *model.Media
|
||||||
|
watchedAt time.Time
|
||||||
|
position int64
|
||||||
|
completed bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
media: &model.Media{LibraryID: movieLib.ID, Title: "电影A", Path: "/media/movies/a.mkv"},
|
||||||
|
watchedAt: yesterday, position: 60000, completed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
media: &model.Media{LibraryID: tvLib.ID, Title: "剧B", Path: "/media/tv/b.mkv"},
|
||||||
|
watchedAt: today, position: 30000, completed: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := svc.Repo.DB.Create(row.media).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := &model.PlaybackHistory{
|
||||||
|
UserID: userID, MediaID: row.media.ID, PositionMs: row.position,
|
||||||
|
DurationMs: 120000, WatchedAt: row.watchedAt, Completed: row.completed,
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := fetchHistoryStats(t, router)
|
||||||
|
|
||||||
|
if payload.Total != 2 {
|
||||||
|
t.Fatalf("total = %d, want 2", payload.Total)
|
||||||
|
}
|
||||||
|
if payload.Completed != 1 {
|
||||||
|
t.Fatalf("completed = %d, want 1", payload.Completed)
|
||||||
|
}
|
||||||
|
if payload.InProgress != 1 {
|
||||||
|
t.Fatalf("in_progress = %d, want 1", payload.InProgress)
|
||||||
|
}
|
||||||
|
if payload.WatchedMs != 90000 {
|
||||||
|
t.Fatalf("watched_ms = %d, want 90000", payload.WatchedMs)
|
||||||
|
}
|
||||||
|
if len(payload.Daily) != 2 {
|
||||||
|
t.Fatalf("daily = %+v, want 2 days", payload.Daily)
|
||||||
|
}
|
||||||
|
if len(payload.ByLibraryType) != 2 {
|
||||||
|
t.Fatalf("by_library_type = %+v, want 2 entries", payload.ByLibraryType)
|
||||||
|
}
|
||||||
|
if len(payload.Recent) != 2 {
|
||||||
|
t.Fatalf("recent = %d entries, want 2", len(payload.Recent))
|
||||||
|
}
|
||||||
|
if payload.Recent[0].Media == nil || payload.Recent[0].Media.Title != "剧B" {
|
||||||
|
t.Fatalf("recent[0] = %+v, want the most recent entry (剧B)", payload.Recent[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有任何播放记录时,新字段要返回空数组而不是 null,前端无需额外判空。
|
||||||
|
func TestHistoryStatsEmptyProvidesEmptyArrays(t *testing.T) {
|
||||||
|
router, _, _ := newHistoryStatsEnv(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/watch-history/stats", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
for _, field := range []string{`"daily":[]`, `"by_library_type":[]`, `"recent":[]`} {
|
||||||
|
if !containsSubstring(body, field) {
|
||||||
|
t.Fatalf("body = %s, want %s", body, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsSubstring(haystack, needle string) bool {
|
||||||
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||||
|
if haystack[i:i+len(needle)] == needle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHistoryStatsBreakdownsVisibilityFilter validates that historyStatsBreakdowns
|
||||||
|
// respects the caller's MediaVisibility: media in a hidden library must be absent
|
||||||
|
// from both the recent list and the by_library_type buckets.
|
||||||
|
func TestHistoryStatsBreakdownsVisibilityFilter(t *testing.T) {
|
||||||
|
_, svc, userID := newHistoryStatsEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
allowedLib := &model.Library{Name: "允许库", Path: "/media/allowed", Type: "movie", Enabled: true}
|
||||||
|
hiddenLib := &model.Library{Name: "隐藏库", Path: "/media/hidden", Type: "tv", Enabled: true}
|
||||||
|
if err := svc.Repo.Library.Create(ctx, allowedLib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.Repo.Library.Create(ctx, hiddenLib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedMedia := &model.Media{LibraryID: allowedLib.ID, Title: "允许媒体", Path: "/media/allowed/a.mkv"}
|
||||||
|
hiddenMedia := &model.Media{LibraryID: hiddenLib.ID, Title: "隐藏媒体", Path: "/media/hidden/b.mkv"}
|
||||||
|
if err := svc.Repo.DB.Create(allowedMedia).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(hiddenMedia).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for _, mid := range []string{allowedMedia.ID, hiddenMedia.ID} {
|
||||||
|
h := &model.PlaybackHistory{
|
||||||
|
UserID: userID, MediaID: mid, PositionMs: 10000,
|
||||||
|
DurationMs: 100000, WatchedAt: now, Completed: false,
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibility that hides hiddenLib
|
||||||
|
vis := service.MediaVisibility{
|
||||||
|
HiddenLibraryIDs: []string{hiddenLib.ID},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, byType, recent := historyStatsBreakdowns(ctx, svc, userID, vis)
|
||||||
|
|
||||||
|
// recent must contain only the allowed media
|
||||||
|
for _, entry := range recent {
|
||||||
|
m, ok := entry["media"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("recent entry missing media field")
|
||||||
|
}
|
||||||
|
switch med := m.(type) {
|
||||||
|
case *model.Media:
|
||||||
|
if med.LibraryID == hiddenLib.ID {
|
||||||
|
t.Fatalf("hidden media appeared in recent: %s", med.Title)
|
||||||
|
}
|
||||||
|
case model.Media:
|
||||||
|
if med.LibraryID == hiddenLib.ID {
|
||||||
|
t.Fatalf("hidden media appeared in recent: %s", med.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(recent) != 1 {
|
||||||
|
t.Fatalf("recent length = %d, want 1 (hidden entry must be excluded)", len(recent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// by_library_type must not contain the hidden library's type ("tv")
|
||||||
|
for _, bt := range byType {
|
||||||
|
if bt.Type == "tv" {
|
||||||
|
t.Fatalf("hidden library type 'tv' appeared in by_library_type (count=%d)", bt.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHistoryStatsBreakdownsNilMediaCountsAsOther confirms that a history row
|
||||||
|
// whose media has been deleted (nil lookup) is counted under the "other" type
|
||||||
|
// bucket rather than silently dropped.
|
||||||
|
func TestHistoryStatsBreakdownsNilMediaCountsAsOther(t *testing.T) {
|
||||||
|
_, svc, userID := newHistoryStatsEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Insert a history row whose media_id does not correspond to any Media row.
|
||||||
|
ghost := &model.PlaybackHistory{
|
||||||
|
UserID: userID,
|
||||||
|
MediaID: "ghost-media-id",
|
||||||
|
PositionMs: 5000,
|
||||||
|
DurationMs: 50000,
|
||||||
|
WatchedAt: time.Now(),
|
||||||
|
Completed: false,
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.Create(ghost).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vis := service.MediaVisibility{} // unrestricted
|
||||||
|
_, byType, _ := historyStatsBreakdowns(ctx, svc, userID, vis)
|
||||||
|
|
||||||
|
var otherEntry *historyTypeStat
|
||||||
|
for i := range byType {
|
||||||
|
if byType[i].Type == "other" {
|
||||||
|
otherEntry = &byType[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if otherEntry == nil {
|
||||||
|
t.Fatalf("expected 'other' bucket for nil-media history row, got %+v", byType)
|
||||||
|
}
|
||||||
|
if otherEntry.Count != 1 {
|
||||||
|
t.Fatalf("other.Count = %d, want 1", otherEntry.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHistoryStatsPermissionDeny checks that a user without can_view_history
|
||||||
|
// receives HTTP 403 from the gated route.
|
||||||
|
func TestHistoryStatsPermissionDeny(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.User{}, &model.Library{}, &model.Media{},
|
||||||
|
&model.PlaybackHistory{}, &model.UserPermission{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||||
|
svc.Permissions = service.NewPermissionService(zap.NewNop(), repos)
|
||||||
|
|
||||||
|
const userID = "user-noperm"
|
||||||
|
if err := repos.User.Create(context.Background(), &model.User{
|
||||||
|
Base: model.Base{ID: userID}, Username: "noperm", PasswordHash: "x", Role: "user", IsActive: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly deny can_view_history for this user.
|
||||||
|
// First seed defaults (Effective will create the row with defaults), then
|
||||||
|
// update to deny via Save which uses an explicit map update path in the repo.
|
||||||
|
if _, err := svc.Permissions.Effective(context.Background(), userID); err != nil {
|
||||||
|
t.Fatalf("seed permissions: %v", err)
|
||||||
|
}
|
||||||
|
denyPerm := &model.UserPermission{UserID: userID, CanViewHistory: false}
|
||||||
|
if err := svc.Permissions.Save(context.Background(), userID, denyPerm); err != nil {
|
||||||
|
t.Fatalf("save permission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
authed := router.Group("/api", func(c *gin.Context) {
|
||||||
|
c.Set(middleware.CtxUserID, userID)
|
||||||
|
c.Set(middleware.CtxUserRole, "user")
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
authed.GET("/watch-history/stats", requirePermission(svc, "can_view_history"), historyStatsHandler(svc))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/watch-history/stats", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403 for user without can_view_history", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,9 @@ type User struct {
|
|||||||
// expires. When set and in the past, the account is treated as expired
|
// expires. When set and in the past, the account is treated as expired
|
||||||
// (login blocked) until an admin or a redemption code renews it.
|
// (login blocked) until an admin or a redemption code renews it.
|
||||||
ExpiredAt *time.Time `json:"expired_at,omitempty"`
|
ExpiredAt *time.Time `json:"expired_at,omitempty"`
|
||||||
|
// TelegramChatID 是用户绑定的 Telegram 会话 ID,用于接收账号与设备通知。
|
||||||
|
// 为空表示未绑定;绑定走个人资料页生成的一次性码 + Bot /bind 命令。
|
||||||
|
TelegramChatID string `gorm:"size:64" json:"telegram_chat_id,omitempty"`
|
||||||
// ShareWarnings counts anti-account-sharing warnings, mainly device
|
// ShareWarnings counts anti-account-sharing warnings, mainly device
|
||||||
// fingerprint mismatches. Once it exceeds the configured threshold a
|
// fingerprint mismatches. Once it exceeds the configured threshold a
|
||||||
// re-offence disables the account until an admin re-enables it.
|
// re-offence disables the account until an admin re-enables it.
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/gorm/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TelegramChatID 是 Telegram 通知的绑定目标:Size 必须容得下真实 chat id
|
||||||
|
// (群/频道 id 为负数且位数更长),因此下限设为 64。
|
||||||
|
func TestUserTelegramChatIDFieldSize(t *testing.T) {
|
||||||
|
parsed, err := schema.Parse(&User{}, &sync.Map{}, schema.NamingStrategy{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
field := parsed.LookUpField("TelegramChatID")
|
||||||
|
if field == nil {
|
||||||
|
t.Fatal("TelegramChatID field not found")
|
||||||
|
}
|
||||||
|
if field.Size < 64 {
|
||||||
|
t.Fatalf("TelegramChatID size = %d, want at least 64", field.Size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newMediaFilterTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Media{}, &model.PlaybackHistory{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedFilterMedia(t *testing.T, db *gorm.DB, rows ...*model.Media) {
|
||||||
|
t.Helper()
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := db.WithContext(context.Background()).Create(row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listFiltered(t *testing.T, db *gorm.DB, filter MediaQueryFilter) []string {
|
||||||
|
t.Helper()
|
||||||
|
var rows []model.Media
|
||||||
|
q := db.WithContext(context.Background()).Model(&model.Media{})
|
||||||
|
q = applyMediaQueryFilter(q, filter)
|
||||||
|
if err := q.Order("title asc").Find(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, row.Title)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTitle(items []string, want string) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if item == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多个类型之间是「或」:勾选 Action 与 Comedy 应同时命中两类。
|
||||||
|
func TestFilterByGenreOR(t *testing.T) {
|
||||||
|
db := newMediaFilterTestDB(t)
|
||||||
|
seedFilterMedia(t, db,
|
||||||
|
&model.Media{Title: "动作", Genres: "Action", Path: "/a.mkv", LibraryID: "lib-1"},
|
||||||
|
&model.Media{Title: "喜剧", Genres: "Comedy", Path: "/b.mkv", LibraryID: "lib-1"},
|
||||||
|
&model.Media{Title: "剧情", Genres: "Drama", Path: "/c.mkv", LibraryID: "lib-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, Genres: []string{"Action", "Comedy"}})
|
||||||
|
if !hasTitle(got, "动作") || !hasTitle(got, "喜剧") {
|
||||||
|
t.Fatalf("result = %v, want both 动作 and 喜剧", got)
|
||||||
|
}
|
||||||
|
if hasTitle(got, "剧情") {
|
||||||
|
t.Fatalf("result = %v, must not contain 剧情", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类型匹配必须是整词匹配:搜 "Action" 不能命中 "ActionComedy" 这类拼接值。
|
||||||
|
func TestFilterByGenreDoesNotMatchSubstring(t *testing.T) {
|
||||||
|
db := newMediaFilterTestDB(t)
|
||||||
|
seedFilterMedia(t, db,
|
||||||
|
&model.Media{Title: "精确", Genres: "Action,Drama", Path: "/a.mkv", LibraryID: "lib-1"},
|
||||||
|
&model.Media{Title: "拼接", Genres: "ActionComedy", Path: "/b.mkv", LibraryID: "lib-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, Genres: []string{"Action"}})
|
||||||
|
if !hasTitle(got, "精确") {
|
||||||
|
t.Fatalf("result = %v, want 精确", got)
|
||||||
|
}
|
||||||
|
if hasTitle(got, "拼接") {
|
||||||
|
t.Fatalf("result = %v, must not match ActionComedy for Action", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterYearAndRating(t *testing.T) {
|
||||||
|
db := newMediaFilterTestDB(t)
|
||||||
|
seedFilterMedia(t, db,
|
||||||
|
&model.Media{Title: "老片", Year: 1995, Rating: 9, Path: "/a.mkv", LibraryID: "lib-1"},
|
||||||
|
&model.Media{Title: "中年", Year: 2010, Rating: 5, Path: "/b.mkv", LibraryID: "lib-1"},
|
||||||
|
&model.Media{Title: "新片", Year: 2023, Rating: 8, Path: "/c.mkv", LibraryID: "lib-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, YearMin: 2000, YearMax: 2020})
|
||||||
|
if len(got) != 1 || got[0] != "中年" {
|
||||||
|
t.Fatalf("year filter result = %v, want [中年]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, RatingMin: 8})
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("rating filter result = %v, want 2 entries", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「未观看」的语义是「没有标记看完的记录」:看了一半的仍应出现。
|
||||||
|
func TestFilterUnwatchedExcludesCompleted(t *testing.T) {
|
||||||
|
db := newMediaFilterTestDB(t)
|
||||||
|
seedFilterMedia(t, db,
|
||||||
|
&model.Media{Base: model.Base{ID: "m-done"}, Title: "看完", Path: "/a.mkv", LibraryID: "lib-1"},
|
||||||
|
&model.Media{Base: model.Base{ID: "m-half"}, Title: "看一半", Path: "/b.mkv", LibraryID: "lib-1"},
|
||||||
|
&model.Media{Base: model.Base{ID: "m-new"}, Title: "没看过", Path: "/c.mkv", LibraryID: "lib-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, h := range []*model.PlaybackHistory{
|
||||||
|
{UserID: "u1", MediaID: "m-done", Completed: true},
|
||||||
|
{UserID: "u1", MediaID: "m-half", Completed: false},
|
||||||
|
// 别人的完播记录不应影响本人筛选。
|
||||||
|
{UserID: "u2", MediaID: "m-new", Completed: true},
|
||||||
|
} {
|
||||||
|
if err := db.WithContext(ctx).Create(h).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got := listFiltered(t, db, MediaQueryFilter{
|
||||||
|
IncludeNSFW: true, UnwatchedOnly: true, UnwatchedUserID: "u1",
|
||||||
|
})
|
||||||
|
if hasTitle(got, "看完") {
|
||||||
|
t.Fatalf("result = %v, must exclude completed media", got)
|
||||||
|
}
|
||||||
|
if !hasTitle(got, "看一半") || !hasTitle(got, "没看过") {
|
||||||
|
t.Fatalf("result = %v, want both 看一半 and 没看过", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多词类型(如 "Science Fiction"):列侧 SQL 会 REPLACE 掉空格,参数侧也必须同步
|
||||||
|
// 去掉空格,两侧对称才能命中。
|
||||||
|
func TestFilterByGenreMultiWordStripsSpaces(t *testing.T) {
|
||||||
|
db := newMediaFilterTestDB(t)
|
||||||
|
seedFilterMedia(t, db,
|
||||||
|
&model.Media{Title: "科幻", Genres: "Science Fiction,Drama", Path: "/a.mkv", LibraryID: "lib-1"},
|
||||||
|
&model.Media{Title: "动作", Genres: "Action", Path: "/b.mkv", LibraryID: "lib-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, Genres: []string{"Science Fiction"}})
|
||||||
|
if !hasTitle(got, "科幻") {
|
||||||
|
t.Fatalf("result = %v, want 科幻 (multi-word genre must match after space stripping)", got)
|
||||||
|
}
|
||||||
|
if hasTitle(got, "动作") {
|
||||||
|
t.Fatalf("result = %v, must not contain 动作", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnwatchedOnly 缺省 userID 时必须忽略该条件,而不是返回空结果。
|
||||||
|
func TestFilterUnwatchedWithoutUserIsIgnored(t *testing.T) {
|
||||||
|
db := newMediaFilterTestDB(t)
|
||||||
|
seedFilterMedia(t, db, &model.Media{Title: "片", Path: "/a.mkv", LibraryID: "lib-1"})
|
||||||
|
|
||||||
|
got := listFiltered(t, db, MediaQueryFilter{IncludeNSFW: true, UnwatchedOnly: true})
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("result = %v, want the row to be returned", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,21 @@ type MediaQueryFilter struct {
|
|||||||
AllowedLibraryIDs []string
|
AllowedLibraryIDs []string
|
||||||
HiddenLibraryIDs []string
|
HiddenLibraryIDs []string
|
||||||
SeriesID string
|
SeriesID string
|
||||||
|
// LibraryID 是精确匹配的单个库过滤,用于库内场景(例如媒体库页筛选)。
|
||||||
|
// 它与 AllowedLibraryIDs 是「与」关系:可见性仍由后者兜底,避免越权。
|
||||||
|
LibraryID string
|
||||||
|
// Genres 是类型多选,之间为「或」。按整词匹配(见 genreMatchClause)。
|
||||||
|
Genres []string
|
||||||
|
// YearMin / YearMax 为 0 表示该端不限。
|
||||||
|
YearMin int
|
||||||
|
YearMax int
|
||||||
|
// RatingMin 为 0 表示不限。
|
||||||
|
RatingMin float64
|
||||||
|
// UnwatchedOnly 排除 UnwatchedUserID 已标记看完的条目。
|
||||||
|
// 「未观看」定义为「没有 completed=true 的记录」:看到一半的仍会出现,
|
||||||
|
// 与「继续观看」互补而不是重复。
|
||||||
|
UnwatchedOnly bool
|
||||||
|
UnwatchedUserID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
||||||
@@ -56,12 +71,113 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
|||||||
if len(filter.AllowedLibraryIDs) > 0 {
|
if len(filter.AllowedLibraryIDs) > 0 {
|
||||||
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
|
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
|
||||||
}
|
}
|
||||||
|
if libraryID := strings.TrimSpace(filter.LibraryID); libraryID != "" {
|
||||||
|
q = q.Where("library_id = ?", libraryID)
|
||||||
|
}
|
||||||
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
|
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
|
||||||
q = q.Where("series_id = ?", seriesID)
|
q = q.Where("series_id = ?", seriesID)
|
||||||
}
|
}
|
||||||
|
if len(filter.Genres) > 0 {
|
||||||
|
q = q.Where(genreMatchClause(filter.Genres), genreMatchArgs(filter.Genres)...)
|
||||||
|
}
|
||||||
|
if filter.YearMin > 0 {
|
||||||
|
q = q.Where("year >= ?", filter.YearMin)
|
||||||
|
}
|
||||||
|
if filter.YearMax > 0 {
|
||||||
|
q = q.Where("year <= ?", filter.YearMax)
|
||||||
|
}
|
||||||
|
if filter.RatingMin > 0 {
|
||||||
|
q = q.Where("rating >= ?", filter.RatingMin)
|
||||||
|
}
|
||||||
|
if filter.UnwatchedOnly {
|
||||||
|
userID := strings.TrimSpace(filter.UnwatchedUserID)
|
||||||
|
// 没有用户上下文时忽略该条件:否则会把整个库筛成空,看起来像「坏了」。
|
||||||
|
if userID != "" {
|
||||||
|
q = q.Where(
|
||||||
|
"id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
|
||||||
|
userID, true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// genreMatchClause 生成类型整词匹配条件。
|
||||||
|
//
|
||||||
|
// genres 列是逗号分隔字符串,直接 LIKE '%Action%' 会把 "ActionComedy" 也命中。
|
||||||
|
// 这里统一补上首尾逗号(并用空格容错)后再按 "%,Action,%" 匹配,实现整词语义;
|
||||||
|
// 该写法在 SQLite 与 PostgreSQL 上行为一致,因此不需要方言分支。
|
||||||
|
//
|
||||||
|
// 注意写法:参数本身带上首尾逗号,SQL 里只做一次 REPLACE 来保证列值两端也有
|
||||||
|
// 分隔符,避免 OR 链里重复拼接列表达式。
|
||||||
|
func genreMatchClause(genres []string) string {
|
||||||
|
clauses := make([]string, 0, len(genres))
|
||||||
|
for range genres {
|
||||||
|
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(genres), ' ', ''), ',', ',') || ',' LIKE ?")
|
||||||
|
}
|
||||||
|
return "(" + strings.Join(clauses, " OR ") + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// genreMatchArgs 生成与 genreMatchClause 对应的参数,形如 "%,Action,%"。
|
||||||
|
//
|
||||||
|
// 必须与 genreMatchClause 的列端处理完全对称:
|
||||||
|
// - TRIM → TrimSpace
|
||||||
|
// - REPLACE(…, ' ', '') → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
|
||||||
|
// - REPLACE(…, ',', ',') → ReplaceAll(…, ",", ",")
|
||||||
|
func genreMatchArgs(genres []string) []any {
|
||||||
|
args := make([]any, 0, len(genres))
|
||||||
|
for _, genre := range genres {
|
||||||
|
name := strings.ReplaceAll(strings.TrimSpace(genre), ",", ",")
|
||||||
|
name = strings.ReplaceAll(name, " ", "") // mirror REPLACE(…,' ','') in genreMatchClause
|
||||||
|
if name == "" {
|
||||||
|
name = "\x00" // 空类型不会命中任何行
|
||||||
|
}
|
||||||
|
args = append(args, "%,"+name+",%")
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGenreValues 返回符合过滤条件的 media.genres 原始值(逗号分隔字符串)。
|
||||||
|
//
|
||||||
|
// 只取单列:类型聚合不需要整行 media,而一台大库的整行扫描会把海报 URL、
|
||||||
|
// 简介等大字段一起读进内存。切分与去重交给调用方,SQL 层保持方言无关。
|
||||||
|
func (r *MediaRepository) ListGenreValues(ctx context.Context, filter MediaQueryFilter) ([]string, error) {
|
||||||
|
var values []string
|
||||||
|
q := r.db.WithContext(ctx).
|
||||||
|
Model(&model.Media{}).
|
||||||
|
Where("genres IS NOT NULL AND genres <> ''")
|
||||||
|
q = applyMediaQueryFilter(q, filter)
|
||||||
|
if err := q.Pluck("genres", &values).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// YearRange 返回符合过滤条件的年份区间(两端都为 0 表示没有可用年份)。
|
||||||
|
// 供媒体库筛选面板生成年份上下限,避免前端硬编码或先取全量再自己算。
|
||||||
|
func (r *MediaRepository) YearRange(ctx context.Context, filter MediaQueryFilter) (int, int, error) {
|
||||||
|
var bounds struct {
|
||||||
|
MinYear *int
|
||||||
|
MaxYear *int
|
||||||
|
}
|
||||||
|
q := r.db.WithContext(ctx).
|
||||||
|
Model(&model.Media{}).
|
||||||
|
Where("year > 0").
|
||||||
|
Select("MIN(year) AS min_year, MAX(year) AS max_year")
|
||||||
|
q = applyMediaQueryFilter(q, filter)
|
||||||
|
if err := q.Scan(&bounds).Error; err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
min, max := 0, 0
|
||||||
|
if bounds.MinYear != nil {
|
||||||
|
min = *bounds.MinYear
|
||||||
|
}
|
||||||
|
if bounds.MaxYear != nil {
|
||||||
|
max = *bounds.MaxYear
|
||||||
|
}
|
||||||
|
return min, max, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MediaRepository) indexMediaBestEffort(ctx context.Context, media model.Media) {
|
func (r *MediaRepository) indexMediaBestEffort(ctx context.Context, media model.Media) {
|
||||||
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
|
backend, ok := r.searchBackend.(MediaSearchSyncBackend)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -21,14 +21,24 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string)
|
|||||||
return fmt.Errorf("device not found")
|
return fmt.Errorf("device not found")
|
||||||
}
|
}
|
||||||
if fp := strings.TrimSpace(d.Fingerprint); fp != "" {
|
if fp := strings.TrimSpace(d.Fingerprint); fp != "" {
|
||||||
return s.repo.UserDevice.SetKickedByFingerprint(ctx, userID, fp, true)
|
err = s.repo.UserDevice.SetKickedByFingerprint(ctx, userID, fp, true)
|
||||||
|
} else {
|
||||||
|
err = s.repo.UserDevice.SetKicked(ctx, d.ID, true)
|
||||||
}
|
}
|
||||||
return s.repo.UserDevice.SetKicked(ctx, d.ID, true)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.notify(ctx, userID, fmt.Sprintf("🔌 设备已下线:<b>%s</b>\n该终端需要重新登录后才能继续使用。", deviceLabel(d.DeviceName, d.Client)))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// KickAllDevices marks all devices for a user as kicked.
|
// KickAllDevices marks all devices for a user as kicked.
|
||||||
func (s *DeviceService) KickAllDevices(ctx context.Context, userID string) error {
|
func (s *DeviceService) KickAllDevices(ctx context.Context, userID string) error {
|
||||||
return s.repo.UserDevice.SetKickedByUser(ctx, userID, true)
|
if err := s.repo.UserDevice.SetKickedByUser(ctx, userID, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.notify(ctx, userID, "🔌 你名下的全部设备已下线,需要重新登录后才能继续使用。")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListDevices returns the device sessions for a user.
|
// ListDevices returns the device sessions for a user.
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newDeviceServiceForTest(t *testing.T) (*DeviceService, *repository.Container, string) {
|
||||||
|
t.Helper()
|
||||||
|
repos := repository.New(newServiceTestDB(t))
|
||||||
|
const userID = "user-1"
|
||||||
|
if err := repos.User.Create(context.Background(), &model.User{
|
||||||
|
Base: model.Base{ID: userID},
|
||||||
|
Username: "tester",
|
||||||
|
PasswordHash: "x",
|
||||||
|
Role: "user",
|
||||||
|
IsActive: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := NewDeviceService(zap.NewNop(), repos)
|
||||||
|
svc.SetSessionTracker(NewSessionTrackerService(zap.NewNop()))
|
||||||
|
return svc, repos, userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新终端首次登录必须通知用户,否则「谁在用我的账号」永远无从察觉。
|
||||||
|
func TestRecordLoginNotifiesOnNewDevice(t *testing.T) {
|
||||||
|
svc, _, userID := newDeviceServiceForTest(t)
|
||||||
|
|
||||||
|
type call struct{ userID, text string }
|
||||||
|
var calls []call
|
||||||
|
svc.SetNotifier(func(_ context.Context, uid, text string) {
|
||||||
|
calls = append(calls, call{uid, text})
|
||||||
|
})
|
||||||
|
|
||||||
|
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
|
||||||
|
|
||||||
|
if len(calls) != 1 {
|
||||||
|
t.Fatalf("notifier calls = %d, want 1", len(calls))
|
||||||
|
}
|
||||||
|
if calls[0].userID != userID {
|
||||||
|
t.Fatalf("notifier user = %q, want %q", calls[0].userID, userID)
|
||||||
|
}
|
||||||
|
if !strings.Contains(calls[0].text, "新设备") {
|
||||||
|
t.Fatalf("notifier text = %q, want it to mention 新设备", calls[0].text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已知终端重复登录不应刷屏:只在首次建档时通知。
|
||||||
|
func TestRecordLoginDoesNotNotifyOnKnownDevice(t *testing.T) {
|
||||||
|
svc, _, userID := newDeviceServiceForTest(t)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
svc.SetNotifier(func(context.Context, string, string) { count++ })
|
||||||
|
|
||||||
|
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
|
||||||
|
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("notifier calls = %d, want exactly 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一键踢下线后必须告知用户,否则只会表现为「播放莫名失败」。
|
||||||
|
func TestKickDeviceNotifiesUser(t *testing.T) {
|
||||||
|
svc, _, userID := newDeviceServiceForTest(t)
|
||||||
|
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
|
||||||
|
|
||||||
|
var kickText string
|
||||||
|
svc.SetNotifier(func(_ context.Context, _, text string) { kickText = text })
|
||||||
|
|
||||||
|
if err := svc.KickAllDevices(context.Background(), userID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if kickText == "" {
|
||||||
|
t.Fatal("expected a notification after kicking devices")
|
||||||
|
}
|
||||||
|
if !strings.Contains(kickText, "已下线") && !strings.Contains(kickText, "踢") {
|
||||||
|
t.Fatalf("kick notification text = %q, want it to describe the kick", kickText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未接线 notifier 时(例如测试环境或 Bot 未配置),所有路径必须保持可用。
|
||||||
|
func TestDeviceServiceWorksWithoutNotifier(t *testing.T) {
|
||||||
|
svc, _, userID := newDeviceServiceForTest(t)
|
||||||
|
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
|
||||||
|
if err := svc.KickAllDevices(context.Background(), userID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备指纹警告除通知用户外,管理员也必须同步收到告警。
|
||||||
|
func TestFingerprintWarnNotifiesAdmin(t *testing.T) {
|
||||||
|
svc, repos, userID := newDeviceServiceForTest(t)
|
||||||
|
|
||||||
|
// 启用防共享策略
|
||||||
|
if err := repos.Setting.Set(context.Background(), SettingAntiShareEnabled, "true"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type call struct{ text string }
|
||||||
|
var adminCalls []call
|
||||||
|
svc.SetAdminNotifier(func(_ context.Context, text string) {
|
||||||
|
adminCalls = append(adminCalls, call{text})
|
||||||
|
})
|
||||||
|
svc.SetNotifier(func(context.Context, string, string) {}) // 用户通知静默接收
|
||||||
|
|
||||||
|
// 第一次登录注册设备
|
||||||
|
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone-A", "Infuse", "1.2.3.4")
|
||||||
|
// 同设备 ID 换设备名 → 触发指纹变更警告
|
||||||
|
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone-B", "Infuse", "1.2.3.4")
|
||||||
|
|
||||||
|
if len(adminCalls) == 0 {
|
||||||
|
t.Fatal("admin must be notified on fingerprint warn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账号因设备策略被禁用时,管理员也必须收到告警。
|
||||||
|
func TestPolicyDisableNotifiesAdmin(t *testing.T) {
|
||||||
|
svc, repos, userID := newDeviceServiceForTest(t)
|
||||||
|
|
||||||
|
// 启用防共享策略,设置最大并发客户端为 1
|
||||||
|
for _, kv := range [][2]string{
|
||||||
|
{SettingAntiShareEnabled, "true"},
|
||||||
|
{SettingMaxLoggedClients, "1"},
|
||||||
|
{SettingClientActiveDays, "30"},
|
||||||
|
} {
|
||||||
|
if err := repos.Setting.Set(context.Background(), kv[0], kv[1]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminCalls []string
|
||||||
|
svc.SetAdminNotifier(func(_ context.Context, text string) {
|
||||||
|
adminCalls = append(adminCalls, text)
|
||||||
|
})
|
||||||
|
svc.SetNotifier(func(context.Context, string, string) {})
|
||||||
|
|
||||||
|
// 两台不同设备登录,超出上限 → 触发禁用
|
||||||
|
svc.RecordLogin(context.Background(), userID, "dev-1", "Phone", "Infuse", "1.2.3.4")
|
||||||
|
svc.RecordLogin(context.Background(), userID, "dev-2", "TV", "Emby", "1.2.3.5")
|
||||||
|
|
||||||
|
if len(adminCalls) == 0 {
|
||||||
|
t.Fatal("admin must be notified when account is disabled by policy")
|
||||||
|
}
|
||||||
|
if !strings.Contains(adminCalls[0], "禁用") {
|
||||||
|
t.Fatalf("admin notification = %q, want it to mention 禁用", adminCalls[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ type DeviceService struct {
|
|||||||
// notifyUser sends a Telegram message to the local user (resolved to their
|
// notifyUser sends a Telegram message to the local user (resolved to their
|
||||||
// Telegram binding). Wired by the bot service; nil disables notifications.
|
// Telegram binding). Wired by the bot service; nil disables notifications.
|
||||||
notifyUser func(ctx context.Context, userID, text string)
|
notifyUser func(ctx context.Context, userID, text string)
|
||||||
|
|
||||||
|
// notifyAdmin sends a Telegram message to all admin accounts.
|
||||||
|
// Wired by the bot service; nil disables notifications.
|
||||||
|
notifyAdmin func(ctx context.Context, text string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDeviceService constructs a DeviceService.
|
// NewDeviceService constructs a DeviceService.
|
||||||
@@ -46,6 +51,11 @@ func (s *DeviceService) SetNotifier(fn func(ctx context.Context, userID, text st
|
|||||||
s.notifyUser = fn
|
s.notifyUser = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAdminNotifier wires the admin-broadcast Telegram notification callback.
|
||||||
|
func (s *DeviceService) SetAdminNotifier(fn func(ctx context.Context, text string)) {
|
||||||
|
s.notifyAdmin = fn
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DeviceService) SetSessionTracker(tracker *SessionTrackerService) {
|
func (s *DeviceService) SetSessionTracker(tracker *SessionTrackerService) {
|
||||||
s.sessions = tracker
|
s.sessions = tracker
|
||||||
}
|
}
|
||||||
@@ -102,6 +112,8 @@ func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, devic
|
|||||||
FirstSeenAt: now,
|
FirstSeenAt: now,
|
||||||
LastSeenAt: now,
|
LastSeenAt: now,
|
||||||
})
|
})
|
||||||
|
// 新终端首次登录才通知:已知设备重复登录不刷屏。
|
||||||
|
s.notify(ctx, userID, fmt.Sprintf("🔔 新设备登录:<b>%s</b>\n如果这不是你本人,请到「个人资料 → 我的设备」踢下线并修改密码。", deviceLabel(deviceName, client)))
|
||||||
} else {
|
} else {
|
||||||
if existing.Fingerprint != "" && existing.Fingerprint != fp {
|
if existing.Fingerprint != "" && existing.Fingerprint != fp {
|
||||||
mismatch = true
|
mismatch = true
|
||||||
@@ -236,7 +248,10 @@ func (s *DeviceService) registerFingerprintWarning(ctx context.Context, userID,
|
|||||||
"last_share_warn_at": &now,
|
"last_share_warn_at": &now,
|
||||||
})
|
})
|
||||||
left := cfg.WarnThreshold + 1 - warnings
|
left := cfg.WarnThreshold + 1 - warnings
|
||||||
s.notify(ctx, userID, fmt.Sprintf("⚠️ 账号 <b>%s</b> 触发设备指纹警告:%s\n这是第 <b>%d</b> 次警告,再异常 <b>%d</b> 次将禁用账号。请使用 Bot 的「我的设备」踢下线异常设备。", u.Username, reason, warnings, left))
|
warnText := fmt.Sprintf("⚠️ 账号 <b>%s</b> 触发设备指纹警告:%s\n这是第 <b>%d</b> 次警告,再异常 <b>%d</b> 次将禁用账号。请到「个人资料 → 我的设备」踢下线异常设备。",
|
||||||
|
html.EscapeString(u.Username), html.EscapeString(reason), warnings, left)
|
||||||
|
s.notify(ctx, userID, warnText)
|
||||||
|
s.notifyAdminMsg(ctx, warnText)
|
||||||
s.log.Info("anti-share: warning issued", zap.String("user", u.Username), zap.Int("warnings", warnings), zap.String("reason", reason))
|
s.log.Info("anti-share: warning issued", zap.String("user", u.Username), zap.Int("warnings", warnings), zap.String("reason", reason))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +270,10 @@ func (s *DeviceService) disableForPolicy(ctx context.Context, userID, reason str
|
|||||||
"last_share_warn_at": &now,
|
"last_share_warn_at": &now,
|
||||||
})
|
})
|
||||||
_ = s.repo.UserDevice.SetKickedByUser(ctx, userID, true)
|
_ = s.repo.UserDevice.SetKickedByUser(ctx, userID, true)
|
||||||
s.notify(ctx, userID, fmt.Sprintf("⛔️ 账号 <b>%s</b> 因触发设备规则已被禁用:%s\n请联系管理员解除禁用,或通过「我的设备」踢下线多余设备后再申请恢复。", u.Username, reason))
|
disableText := fmt.Sprintf("⛔️ 账号 <b>%s</b> 因触发设备规则已被禁用:%s\n请联系管理员解除禁用,或通过「个人资料 → 我的设备」踢下线多余设备后再申请恢复。",
|
||||||
|
html.EscapeString(u.Username), html.EscapeString(reason))
|
||||||
|
s.notify(ctx, userID, disableText)
|
||||||
|
s.notifyAdminMsg(ctx, disableText)
|
||||||
s.log.Warn("device policy: disabled account", zap.String("user", u.Username), zap.String("reason", reason))
|
s.log.Warn("device policy: disabled account", zap.String("user", u.Username), zap.String("reason", reason))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +294,12 @@ func (s *DeviceService) notify(ctx context.Context, userID, text string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DeviceService) notifyAdminMsg(ctx context.Context, text string) {
|
||||||
|
if s.notifyAdmin != nil {
|
||||||
|
s.notifyAdmin(ctx, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func deviceLabel(name, client string) string {
|
func deviceLabel(name, client string) string {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
client = strings.TrimSpace(client)
|
client = strings.TrimSpace(client)
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ type EmbyService struct {
|
|||||||
adult *AdultProvider
|
adult *AdultProvider
|
||||||
personImageMu sync.RWMutex
|
personImageMu sync.RWMutex
|
||||||
personImages map[string]string
|
personImages map[string]string
|
||||||
|
|
||||||
|
// discovery 提供 NextUp / Similar / Genres 的候选集。它只选候选,
|
||||||
|
// DTO 形状仍由本服务统一产出,避免同一部剧在不同接口上长得不一样。
|
||||||
|
discovery *MediaDiscoveryService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEmbyService is the constructor.
|
// NewEmbyService is the constructor.
|
||||||
@@ -91,6 +95,26 @@ func (e *EmbyService) SetEmbyRemote(remote *EmbyRemoteService) *EmbyService {
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDiscovery 注入发现类查询服务(NextUp / Similar / Genres)。
|
||||||
|
func (e *EmbyService) SetDiscovery(discovery *MediaDiscoveryService) *EmbyService {
|
||||||
|
if e != nil {
|
||||||
|
e.discovery = discovery
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoveryService 返回发现服务;未注入时按需构造,保证 Emby 接口在任何
|
||||||
|
// 组装顺序下都不会因为缺少注入而返回空结果。
|
||||||
|
func (e *EmbyService) discoveryService() *MediaDiscoveryService {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if e.discovery == nil {
|
||||||
|
e.discovery = NewMediaDiscoveryService(e.log, e.repo)
|
||||||
|
}
|
||||||
|
return e.discovery
|
||||||
|
}
|
||||||
|
|
||||||
// SetTMDbProvider wires the TMDb client used for detail-time cast/crew lookup.
|
// SetTMDbProvider wires the TMDb client used for detail-time cast/crew lookup.
|
||||||
func (e *EmbyService) SetTMDbProvider(tmdb *TMDbProvider) *EmbyService {
|
func (e *EmbyService) SetTMDbProvider(tmdb *TMDbProvider) *EmbyService {
|
||||||
if e != nil {
|
if e != nil {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -65,3 +67,133 @@ func TestEmbyLatestItemsOrderByReleaseDate(t *testing.T) {
|
|||||||
t.Fatalf("latest item should expose PremiereDate for Emby clients: %#v", items[0])
|
t.Fatalf("latest item should expose PremiereDate for Emby clients: %#v", items[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SimilarItems with a real series_id (not a media table row ID) must return
|
||||||
|
// results instead of an empty list. Before bug-2 fix, Media.FindByID returned
|
||||||
|
// nil for any ID that wasn't a primary-key match in the media table (including
|
||||||
|
// series_id values and virtual msgo-series-* IDs), so SimilarItems always
|
||||||
|
// returned empty for series detail pages.
|
||||||
|
func TestEmbyServiceSimilarItemsSeriesID(t *testing.T) {
|
||||||
|
svc := newTestEmbyService(t)
|
||||||
|
svc.SetDiscovery(NewMediaDiscoveryService(zap.NewNop(), svc.repo))
|
||||||
|
|
||||||
|
lib := model.Library{Name: "TV", Path: "/media/tv", Type: "tv", Enabled: true}
|
||||||
|
if err := svc.repo.Library.Create(context.Background(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two series with the same genre so they score > 0 for similarity.
|
||||||
|
ep1 := model.Media{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
SeriesID: "real-series-1",
|
||||||
|
Title: "剧一",
|
||||||
|
Genres: "Action",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
Path: "/media/tv/s1e1.mkv",
|
||||||
|
}
|
||||||
|
ep2 := model.Media{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
SeriesID: "real-series-2",
|
||||||
|
Title: "剧二",
|
||||||
|
Genres: "Action",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
Path: "/media/tv/s2e1.mkv",
|
||||||
|
}
|
||||||
|
for _, m := range []*model.Media{&ep1, &ep2} {
|
||||||
|
if err := svc.repo.DB.Create(m).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "real-series-1" is the series_id stored in the media row but is NOT a
|
||||||
|
// primary key in the media table, so Media.FindByID("real-series-1") returns
|
||||||
|
// nil. SimilarItems must fall back to findSeriesGroup and still return
|
||||||
|
// results.
|
||||||
|
result, err := svc.SimilarItems(context.Background(), "real-series-1", "", 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SimilarItems with series_id: %v", err)
|
||||||
|
}
|
||||||
|
similar, _ := result["Items"].([]map[string]any)
|
||||||
|
if similar == nil {
|
||||||
|
t.Fatal("SimilarItems returned nil items for a series_id that resolves via findSeriesGroup")
|
||||||
|
}
|
||||||
|
// Should contain 剧二 (the only other episodic content with the same genre).
|
||||||
|
found := false
|
||||||
|
for _, item := range similar {
|
||||||
|
if name, _ := item["Name"].(string); strings.Contains(name, "剧二") || strings.Contains(name, "第 1 集") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found && len(similar) == 0 {
|
||||||
|
t.Fatalf("SimilarItems returned no results; want at least 剧二 for series real-series-1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarItems with a virtual series ID (msgo-series-*) must also work.
|
||||||
|
// Virtual IDs are generated for episodes that have no series_id set.
|
||||||
|
func TestEmbyServiceSimilarItemsVirtualSeriesID(t *testing.T) {
|
||||||
|
svc := newTestEmbyService(t)
|
||||||
|
svc.SetDiscovery(NewMediaDiscoveryService(zap.NewNop(), svc.repo))
|
||||||
|
|
||||||
|
lib := model.Library{Name: "TV2", Path: "/media/tv2", Type: "tv", Enabled: true}
|
||||||
|
if err := svc.repo.Library.Create(context.Background(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Episodes WITHOUT SeriesID → series group gets a virtual msgo-series-* ID.
|
||||||
|
ep1 := model.Media{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "虚拟剧一",
|
||||||
|
Genres: "Drama",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
Path: "/media/tv2/virtual1/S01E01.mkv",
|
||||||
|
}
|
||||||
|
ep2 := model.Media{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "虚拟剧二",
|
||||||
|
Genres: "Drama",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 1,
|
||||||
|
Path: "/media/tv2/virtual2/S01E01.mkv",
|
||||||
|
}
|
||||||
|
for _, m := range []*model.Media{&ep1, &ep2} {
|
||||||
|
if err := svc.repo.DB.Create(m).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch series items to get the virtual ID for ep1's series.
|
||||||
|
seriesItems, err := svc.Items(context.Background(), ItemsParams{
|
||||||
|
IncludeItemTypes: []string{"Series"},
|
||||||
|
Recursive: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, _ := seriesItems["Items"].([]map[string]any)
|
||||||
|
var virtualID string
|
||||||
|
for _, item := range items {
|
||||||
|
id, _ := item["Id"].(string)
|
||||||
|
name, _ := item["Name"].(string)
|
||||||
|
if strings.Contains(name, "虚拟剧一") && strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||||
|
virtualID = id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if virtualID == "" {
|
||||||
|
t.Skip("virtual series ID not generated for episodes without series_id in this build")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := svc.SimilarItems(context.Background(), virtualID, "", 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SimilarItems with virtual series ID: %v", err)
|
||||||
|
}
|
||||||
|
similar, _ := result["Items"].([]map[string]any)
|
||||||
|
if similar == nil {
|
||||||
|
t.Fatalf("SimilarItems returned nil items for virtual series ID %q", virtualID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emby 发现类接口:NextUp / Similar / Genres。
|
||||||
|
//
|
||||||
|
// 候选集的选取交给 MediaDiscoveryService(纯查询、无 DTO 概念),本文件只做
|
||||||
|
// 「候选 → Emby DTO」的映射,复用 itemPayload 以保证与 /Items 的形状一致。
|
||||||
|
|
||||||
|
const (
|
||||||
|
embyNextUpDefaultLimit = 20
|
||||||
|
embySimilarDefaultLimit = 12
|
||||||
|
embyNextUpMaxLimit = 100
|
||||||
|
embySimilarMaxLimit = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// NextUp 返回「每部在看的剧的下一集」,即 Emby 客户端首页「接下来播放」的数据源。
|
||||||
|
func (e *EmbyService) NextUp(ctx context.Context, userID string, limit int) (map[string]any, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = embyNextUpDefaultLimit
|
||||||
|
}
|
||||||
|
if limit > embyNextUpMaxLimit {
|
||||||
|
limit = embyNextUpMaxLimit
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(userID) == "" {
|
||||||
|
return emptyItemsEnvelope(0), nil
|
||||||
|
}
|
||||||
|
discovery := e.discoveryService()
|
||||||
|
if discovery == nil {
|
||||||
|
return emptyItemsEnvelope(0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := discovery.NextUpCandidates(ctx, userID, limit, e.mediaVisibility(ctx, userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Bug 3 fix: use payloadsForMedia which attaches the request-scoped payload
|
||||||
|
// cache (withPayloadCache + prefetchPayloadCache) to avoid N+1 DB queries.
|
||||||
|
items, err := e.payloadsForMedia(ctx, rows, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"Items": items,
|
||||||
|
"TotalRecordCount": int64(len(items)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SimilarItems 返回与指定条目相似的本地媒体。
|
||||||
|
//
|
||||||
|
// 找不到条目(或该条目对当前用户不可见)时返回空列表而不是错误:客户端会在
|
||||||
|
// 详情页无条件请求它,404/500 会让客户端把条目判定为不完整。
|
||||||
|
func (e *EmbyService) SimilarItems(ctx context.Context, mediaID, userID string, limit int) (map[string]any, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = embySimilarDefaultLimit
|
||||||
|
}
|
||||||
|
if limit > embySimilarMaxLimit {
|
||||||
|
limit = embySimilarMaxLimit
|
||||||
|
}
|
||||||
|
discovery := e.discoveryService()
|
||||||
|
if discovery == nil || strings.TrimSpace(mediaID) == "" {
|
||||||
|
return emptyItemsEnvelope(0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bug 2 fix: resolve virtual series IDs (msgo-series-*) and real series
|
||||||
|
// table IDs to a representative episode so SimilarCandidates (which calls
|
||||||
|
// Media.FindByID) can seed similarity from concrete media metadata.
|
||||||
|
resolvedID := mediaID
|
||||||
|
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
|
||||||
|
series, ok, err := e.findSeriesGroup(ctx, mediaID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok || len(series.Episodes) == 0 {
|
||||||
|
return emptyItemsEnvelope(0), nil
|
||||||
|
}
|
||||||
|
resolvedID = series.Episodes[0].ID
|
||||||
|
} else if e.repo != nil && e.repo.Media != nil {
|
||||||
|
// For non-virtual IDs that are series-level (not in media table), also
|
||||||
|
// resolve via findSeriesGroup so the seed row can be found.
|
||||||
|
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
series, ok, err := e.findSeriesGroup(ctx, mediaID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok || len(series.Episodes) == 0 {
|
||||||
|
return emptyItemsEnvelope(0), nil
|
||||||
|
}
|
||||||
|
resolvedID = series.Episodes[0].ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := discovery.SimilarCandidates(ctx, resolvedID, limit, e.mediaVisibility(ctx, userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Bug 3 fix: use payloadsForMedia which attaches the request-scoped payload
|
||||||
|
// cache (withPayloadCache + prefetchPayloadCache) to avoid N+1 DB queries.
|
||||||
|
items, err := e.payloadsForMedia(ctx, rows, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"Items": items,
|
||||||
|
"TotalRecordCount": int64(len(items)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genres 返回类型清单。parentID 非空时(客户端按媒体库浏览类型)只统计该库。
|
||||||
|
func (e *EmbyService) Genres(ctx context.Context, userID, parentID string) (map[string]any, error) {
|
||||||
|
discovery := e.discoveryService()
|
||||||
|
if discovery == nil {
|
||||||
|
return emptyItemsEnvelope(0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryID := ""
|
||||||
|
if trimmed := strings.TrimSpace(parentID); trimmed != "" {
|
||||||
|
// 只有本地的真实库 ID 才能用于收窄;虚拟视图 ID(Emby 客户端自己的
|
||||||
|
// 视图标识)收窄后会得到空结果,因此识别不出来时按全库统计。
|
||||||
|
if e.libraryExists(ctx, trimmed) {
|
||||||
|
libraryID = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
genres, err := discovery.AggregateGenres(ctx, e.mediaVisibility(ctx, userID), libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0, len(genres))
|
||||||
|
for _, genre := range genres {
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"Id": embyGenreID(genre.Name),
|
||||||
|
"Name": genre.Name,
|
||||||
|
"ItemCount": genre.Count,
|
||||||
|
"Type": "Genre",
|
||||||
|
"ServerId": embyServerID,
|
||||||
|
"IsFolder": false,
|
||||||
|
"CanDelete": false,
|
||||||
|
"CanDownload": false,
|
||||||
|
"ImageTags": map[string]any{},
|
||||||
|
"BackdropImageTags": []any{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"Items": items,
|
||||||
|
"TotalRecordCount": int64(len(items)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryExists 判断 ID 是否对应本地媒体库。
|
||||||
|
func (e *EmbyService) libraryExists(ctx context.Context, id string) bool {
|
||||||
|
if e == nil || e.repo == nil || e.repo.Library == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lib, err := e.repo.Library.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return lib != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// embyGenreID 为类型生成稳定的虚拟 ID。
|
||||||
|
//
|
||||||
|
// 客户端会把 Id 当作条目去请求图片/详情,直接用类型名会带上空格与非 ASCII
|
||||||
|
// 字符,因此用固定前缀 + 名称哈希;同一个名称永远得到同一个 ID。
|
||||||
|
func embyGenreID(name string) string {
|
||||||
|
sum := sha1.Sum([]byte(strings.ToLower(strings.TrimSpace(name))))
|
||||||
|
return "msgo-genre-" + hex.EncodeToString(sum[:])[:16]
|
||||||
|
}
|
||||||
@@ -32,10 +32,26 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string,
|
|||||||
strings.Join(allowed, ","),
|
strings.Join(allowed, ","),
|
||||||
strings.Join(hidden, ","),
|
strings.Join(hidden, ","),
|
||||||
filter.SeriesID,
|
filter.SeriesID,
|
||||||
|
filterFingerprint(filter),
|
||||||
}, "|")))
|
}, "|")))
|
||||||
return "media:list:" + hex.EncodeToString(sum[:])
|
return "media:list:" + hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterFingerprint 把影响结果的筛选维度序列化成稳定字符串。
|
||||||
|
//
|
||||||
|
// 缓存键必须覆盖所有会改变结果的过滤条件:漏一个就会出现「先打开未筛选列表,
|
||||||
|
// 再筛选时命中旧缓存」这类脏读(返回不带筛选的数据)。新增过滤字段时只改这里。
|
||||||
|
func filterFingerprint(filter repository.MediaQueryFilter) string {
|
||||||
|
genres := append([]string(nil), filter.Genres...)
|
||||||
|
sort.Strings(genres)
|
||||||
|
return strings.Join([]string{
|
||||||
|
"genres=" + strings.Join(genres, ","),
|
||||||
|
fmt.Sprintf("year=%d-%d", filter.YearMin, filter.YearMax),
|
||||||
|
fmt.Sprintf("rating=%.2f", filter.RatingMin),
|
||||||
|
fmt.Sprintf("unwatched=%t:%s", filter.UnwatchedOnly, filter.UnwatchedUserID),
|
||||||
|
}, "&")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter, includeCounts bool) string {
|
func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter, includeCounts bool) string {
|
||||||
libIDs := make([]string, len(libraries))
|
libIDs := make([]string, len(libraries))
|
||||||
for i, lib := range libraries {
|
for i, lib := range libraries {
|
||||||
@@ -138,6 +154,7 @@ func (s *MediaService) groupedItemsCacheKey(libraryID string, libraryIDs []strin
|
|||||||
strings.Join(allowed, ","),
|
strings.Join(allowed, ","),
|
||||||
strings.Join(hidden, ","),
|
strings.Join(hidden, ","),
|
||||||
filter.SeriesID,
|
filter.SeriesID,
|
||||||
|
filterFingerprint(filter),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MediaDiscoveryService 提供「发现类」查询:类型聚合、下一集、相似内容。
|
||||||
|
//
|
||||||
|
// 它只负责候选集的选取,不产出 Emby DTO —— DTO 形状必须由 EmbyService 统一
|
||||||
|
// 提供,否则同一部剧在 /Items 与 /Shows/NextUp 上会长得不一样。同理,这里
|
||||||
|
// 只接受调用方传入的 MediaVisibility,不自己解析用户权限。
|
||||||
|
type MediaDiscoveryService struct {
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenreCount 是类型聚合结果。Name 保留首次出现时的写法(大小写与全半角
|
||||||
|
// 均按原样展示),计数则不区分大小写。
|
||||||
|
type GenreCount struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMediaDiscoveryService 构建发现服务。
|
||||||
|
func NewMediaDiscoveryService(log *zap.Logger, repo *repository.Container) *MediaDiscoveryService {
|
||||||
|
return &MediaDiscoveryService{log: log, repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaFilterFromVisibility 把可见性翻译成仓储过滤条件。所有发现类查询都必须
|
||||||
|
// 经过这里,避免某一处忘记过滤 NSFW 或受限媒体库。
|
||||||
|
func mediaFilterFromVisibility(visibility MediaVisibility) repository.MediaQueryFilter {
|
||||||
|
return repository.MediaQueryFilter{
|
||||||
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||||
|
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateGenres 统计可见媒体的类型分布。libraryID 非空时只统计该库,
|
||||||
|
// 供媒体库页的筛选项与 Emby /Genres 共用。
|
||||||
|
func (s *MediaDiscoveryService) AggregateGenres(ctx context.Context, visibility MediaVisibility, libraryID string) ([]GenreCount, error) {
|
||||||
|
if s == nil || s.repo == nil || s.repo.Media == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
filter := mediaFilterFromVisibility(visibility)
|
||||||
|
filter.LibraryID = strings.TrimSpace(libraryID)
|
||||||
|
|
||||||
|
raw, err := s.repo.Media.ListGenreValues(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return countGenres(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countGenres 切分并计数。大小写不同的同名类型合并计数,展示名取首次出现的
|
||||||
|
// 写法;结果按 count 降序、同数按名称升序,保证输出稳定可测。
|
||||||
|
func countGenres(values []string) []GenreCount {
|
||||||
|
counts := make(map[string]int)
|
||||||
|
display := make(map[string]string)
|
||||||
|
for _, value := range values {
|
||||||
|
for _, name := range SplitGenreList(value) {
|
||||||
|
key := strings.ToLower(name)
|
||||||
|
if _, seen := display[key]; !seen {
|
||||||
|
display[key] = name
|
||||||
|
}
|
||||||
|
counts[key]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]GenreCount, 0, len(counts))
|
||||||
|
for key, count := range counts {
|
||||||
|
out = append(out, GenreCount{Name: display[key], Count: count})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Count != out[j].Count {
|
||||||
|
return out[i].Count > out[j].Count
|
||||||
|
}
|
||||||
|
return out[i].Name < out[j].Name
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitGenreList 切分逗号分隔的类型字段。
|
||||||
|
//
|
||||||
|
// 刮削来源既有英文逗号也有中文全角逗号,且常见首尾空格,因此三种分隔符都要
|
||||||
|
// 处理,并丢弃空段。
|
||||||
|
func SplitGenreList(value string) []string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.FieldsFunc(value, func(r rune) bool {
|
||||||
|
return r == ',' || r == ',' || r == '、' || r == ';' || r == ';'
|
||||||
|
})
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// genreSet 把类型字段转成小写集合,用于相似度计算。
|
||||||
|
func genreSet(value string) map[string]struct{} {
|
||||||
|
names := SplitGenreList(value)
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]struct{}, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
out[strings.ToLower(name)] = struct{}{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// genreOverlap 返回两个类型集合的交集大小。
|
||||||
|
func genreOverlap(a, b map[string]struct{}) int {
|
||||||
|
if len(a) == 0 || len(b) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// 遍历较小的集合,减少比较次数。
|
||||||
|
if len(b) < len(a) {
|
||||||
|
a, b = b, a
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for name := range a {
|
||||||
|
if _, ok := b[name]; ok {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaIsEpisode 判断一行 media 是否属于「剧集」维度。
|
||||||
|
//
|
||||||
|
// 与 Emby 的判定保持一致(季号或集号大于 0),但不依赖 library type:同一个
|
||||||
|
// 库既可能放电影也可能放剧集,用编号判断更贴近实际数据。
|
||||||
|
func mediaIsEpisode(m *model.Media) bool {
|
||||||
|
return m != nil && (m.SeasonNum > 0 || m.EpisodeNum > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesGroupKey 是「同一部剧」的归并键。SeriesID 优先;缺省时退回
|
||||||
|
// (库, 标题),这样未刮削的剧集也能归到一组而不是每条历史各算一部剧。
|
||||||
|
func seriesGroupKey(m *model.Media) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if key := strings.TrimSpace(m.SeriesID); key != "" {
|
||||||
|
return "sid:" + key
|
||||||
|
}
|
||||||
|
if !mediaIsEpisode(m) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "lib:" + m.LibraryID + "|title:" + strings.ToLower(strings.TrimSpace(m.Title))
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextUpHistoryScanLimit 是扫描播放历史的上限。历史按最近观看倒序取,
|
||||||
|
// 因此截断只会丢掉「很久没看且排在很后面」的剧,不会影响首页前排。
|
||||||
|
const nextUpHistoryScanLimit = 100
|
||||||
|
|
||||||
|
// NextUpCandidates 返回「每部在看的剧的下一个待看集」,按最近观看时间排序。
|
||||||
|
//
|
||||||
|
// 语义要点:
|
||||||
|
// - 只处理剧集,电影由 Resume 接口负责,避免两个接口内容重复。
|
||||||
|
// - 同一部剧最多一条:取最近看过的集合之后、编号最小的那集。
|
||||||
|
// - 已标记看完的集跳过;追到最后一集则该剧不出现在结果里。
|
||||||
|
func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID string, limit int, visibility MediaVisibility) ([]model.Media, error) {
|
||||||
|
if s == nil || s.repo == nil || s.repo.Media == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(userID) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bug 1 fix: include completed histories as anchors so a finished episode
|
||||||
|
// still anchors its series and the next unwatched episode is picked.
|
||||||
|
var histories []model.PlaybackHistory
|
||||||
|
if err := s.repo.DB.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND position_ms > 0", userID).
|
||||||
|
Order("watched_at desc").
|
||||||
|
Limit(nextUpHistoryScanLimit).
|
||||||
|
Find(&histories).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(histories) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaIDs := make([]string, 0, len(histories))
|
||||||
|
for _, h := range histories {
|
||||||
|
mediaIDs = append(mediaIDs, h.MediaID)
|
||||||
|
}
|
||||||
|
filter := mediaFilterFromVisibility(visibility)
|
||||||
|
var watchedRows []model.Media
|
||||||
|
q := s.repo.DB.WithContext(ctx).Where("id IN ?", mediaIDs)
|
||||||
|
q = applyDiscoveryVisibility(q, filter)
|
||||||
|
if err := q.Find(&watchedRows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byID := make(map[string]*model.Media, len(watchedRows))
|
||||||
|
for i := range watchedRows {
|
||||||
|
byID[watchedRows[i].ID] = &watchedRows[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按最近观看顺序归并到「剧」维度,同时记住该剧最近的已看集。
|
||||||
|
type seriesState struct {
|
||||||
|
key string
|
||||||
|
current *model.Media
|
||||||
|
}
|
||||||
|
states := make([]seriesState, 0, len(histories))
|
||||||
|
seen := make(map[string]bool, len(histories))
|
||||||
|
for _, h := range histories {
|
||||||
|
m := byID[h.MediaID]
|
||||||
|
if m == nil || !mediaIsEpisode(m) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := seriesGroupKey(m)
|
||||||
|
if key == "" || seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
states = append(states, seriesState{key: key, current: m})
|
||||||
|
}
|
||||||
|
if len(states) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一次性把涉及的剧集全部取回,避免按剧逐条查询。
|
||||||
|
//
|
||||||
|
// Bug 4 fix: group by library_id when fetching by series_id so episodes
|
||||||
|
// from a different library with the same series_id don't bleed in.
|
||||||
|
// Bug 5 fix: batch unscraped (library_id, title) lookups per library
|
||||||
|
// instead of one query per series.
|
||||||
|
byLibSeries := make(map[string][]string) // libID -> []seriesID
|
||||||
|
var fallback []seriesState
|
||||||
|
for _, st := range states {
|
||||||
|
if id := strings.TrimSpace(st.current.SeriesID); id != "" {
|
||||||
|
byLibSeries[st.current.LibraryID] = append(byLibSeries[st.current.LibraryID], id)
|
||||||
|
} else {
|
||||||
|
fallback = append(fallback, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
episodes := make([]model.Media, 0, len(states)*8)
|
||||||
|
// 按库批量加载刮削剧集,避免跨库混入同名 series_id 的剧集。
|
||||||
|
for libID, sids := range byLibSeries {
|
||||||
|
var rows []model.Media
|
||||||
|
eq := s.repo.DB.WithContext(ctx).Where("series_id IN ? AND library_id = ?", sids, libID)
|
||||||
|
eq = applyDiscoveryVisibility(eq, filter)
|
||||||
|
if err := eq.Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
episodes = append(episodes, rows...)
|
||||||
|
}
|
||||||
|
// 未刮削剧集按 (库, 标题) 批量兜底查询,每库一条 SQL 避免 N+1。
|
||||||
|
byLibTitles := make(map[string][]string) // libID -> []title
|
||||||
|
for _, st := range fallback {
|
||||||
|
byLibTitles[st.current.LibraryID] = append(byLibTitles[st.current.LibraryID], st.current.Title)
|
||||||
|
}
|
||||||
|
for libID, titles := range byLibTitles {
|
||||||
|
var rows []model.Media
|
||||||
|
fq := s.repo.DB.WithContext(ctx).Where("library_id = ? AND title IN ?", libID, titles)
|
||||||
|
fq = applyDiscoveryVisibility(fq, filter)
|
||||||
|
if err := fq.Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
episodes = append(episodes, rows...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按剧归并候选集,便于 O(1) 查找下一集。
|
||||||
|
bySeries := make(map[string][]model.Media, len(states))
|
||||||
|
for _, row := range episodes {
|
||||||
|
key := seriesGroupKey(&row)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bySeries[key] = append(bySeries[key], row)
|
||||||
|
}
|
||||||
|
completed := s.completedMediaIDs(ctx, userID, episodes)
|
||||||
|
|
||||||
|
out := make([]model.Media, 0, limit)
|
||||||
|
for _, st := range states {
|
||||||
|
if len(out) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
next, ok := pickNextEpisode(bySeries[st.key], st.current, completed)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, next)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDiscoveryVisibility 把可见性过滤应用到查询上。
|
||||||
|
func applyDiscoveryVisibility(q *gorm.DB, filter repository.MediaQueryFilter) *gorm.DB {
|
||||||
|
if !filter.IncludeNSFW {
|
||||||
|
q = q.Where("nsfw = ?", false)
|
||||||
|
}
|
||||||
|
if len(filter.HiddenLibraryIDs) > 0 {
|
||||||
|
q = q.Where("library_id NOT IN ?", filter.HiddenLibraryIDs)
|
||||||
|
}
|
||||||
|
if len(filter.AllowedLibraryIDs) > 0 {
|
||||||
|
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
|
||||||
|
}
|
||||||
|
if libraryID := strings.TrimSpace(filter.LibraryID); libraryID != "" {
|
||||||
|
q = q.Where("library_id = ?", libraryID)
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// completedMediaIDs 找出这些候选里该用户已标记看完的集。
|
||||||
|
func (s *MediaDiscoveryService) completedMediaIDs(ctx context.Context, userID string, rows []model.Media) map[string]bool {
|
||||||
|
out := make(map[string]bool)
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
ids = append(ids, row.ID)
|
||||||
|
}
|
||||||
|
var done []model.PlaybackHistory
|
||||||
|
if err := s.repo.DB.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND completed = ? AND media_id IN ?", userID, true, ids).
|
||||||
|
Find(&done).Error; err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, h := range done {
|
||||||
|
out[h.MediaID] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickNextEpisode 在候选集中选出严格晚于 current 的、编号最小的一集。
|
||||||
|
// 比较顺序为 (季, 集),因此跨季时自然落到下一季第一集。
|
||||||
|
func pickNextEpisode(candidates []model.Media, current *model.Media, completed map[string]bool) (model.Media, bool) {
|
||||||
|
var best model.Media
|
||||||
|
found := false
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate.ID == current.ID || completed[candidate.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !episodeAfter(candidate, *current) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !found || episodeBefore(candidate, best) {
|
||||||
|
best = candidate
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, found
|
||||||
|
}
|
||||||
|
|
||||||
|
// episodeAfter 报告 a 是否严格晚于 b。
|
||||||
|
func episodeAfter(a, b model.Media) bool {
|
||||||
|
if a.SeasonNum != b.SeasonNum {
|
||||||
|
return a.SeasonNum > b.SeasonNum
|
||||||
|
}
|
||||||
|
return a.EpisodeNum > b.EpisodeNum
|
||||||
|
}
|
||||||
|
|
||||||
|
// episodeBefore 报告 a 是否严格早于 b。
|
||||||
|
func episodeBefore(a, b model.Media) bool {
|
||||||
|
if a.SeasonNum != b.SeasonNum {
|
||||||
|
return a.SeasonNum < b.SeasonNum
|
||||||
|
}
|
||||||
|
return a.EpisodeNum < b.EpisodeNum
|
||||||
|
}
|
||||||
|
|
||||||
|
// similarCandidateLimit 是每个来源池(同库 / 同类型其他库)的候选上限。
|
||||||
|
//
|
||||||
|
// 相似度需要在内存里按类型/年份/评分算分,因此不能把整库拉出来;按评分倒序
|
||||||
|
// 取前 N 条是「好的片子更可能被推荐」与「查询有界」之间的折中。
|
||||||
|
const similarCandidateLimit = 400
|
||||||
|
|
||||||
|
// SimilarCandidates 返回与源条目相似的本地媒体。
|
||||||
|
//
|
||||||
|
// 打分口径(不依赖任何外部 API,离线可用):
|
||||||
|
// - 类型重合数 × 10:最强信号,同类内容通常才谈得上相似;
|
||||||
|
// - 年份接近度:相差 5 年内给分,差得越远越低;
|
||||||
|
// - 评分接近度:同为高分片算加分,避免「8 分片旁边推 3 分片」。
|
||||||
|
//
|
||||||
|
// 同库优先;不足时才从同类型的其它可见库里补齐。同剧其它集与自身一律排除。
|
||||||
|
func (s *MediaDiscoveryService) SimilarCandidates(ctx context.Context, mediaID string, limit int, visibility MediaVisibility) ([]model.Media, error) {
|
||||||
|
if s == nil || s.repo == nil || s.repo.Media == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 12
|
||||||
|
}
|
||||||
|
source, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if source == nil || !visibility.Allows(source) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := mediaFilterFromVisibility(visibility)
|
||||||
|
|
||||||
|
pool, err := s.similarPool(ctx, filter, source, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(pool) < limit {
|
||||||
|
// 同库不够时再扩到同类型库,保持「电影配电影、剧集配剧集」的直觉。
|
||||||
|
more, err := s.similarPool(ctx, filter, source, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pool = append(pool, more...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rankSimilar(source, pool, limit), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// similarPool 取一批候选。expand=true 时排除源所在的库(用于补齐阶段),
|
||||||
|
// 否则只取源所在的库(首选阶段)。
|
||||||
|
func (s *MediaDiscoveryService) similarPool(ctx context.Context, filter repository.MediaQueryFilter, source *model.Media, expand bool) ([]model.Media, error) {
|
||||||
|
q := s.repo.DB.WithContext(ctx).Model(&model.Media{})
|
||||||
|
q = applyDiscoveryVisibility(q, filter)
|
||||||
|
q = q.Where("id <> ?", source.ID)
|
||||||
|
|
||||||
|
libraryIDs := []string{source.LibraryID}
|
||||||
|
if expand {
|
||||||
|
ids, err := s.compatibleLibraryIDs(ctx, source)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
filtered := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id != source.LibraryID {
|
||||||
|
filtered = append(filtered, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
libraryIDs = filtered
|
||||||
|
}
|
||||||
|
q = q.Where("library_id IN ?", libraryIDs)
|
||||||
|
|
||||||
|
// 电影与剧集不互相推荐:用集号判定,和 NextUp 保持同一套口径。
|
||||||
|
if mediaIsEpisode(source) {
|
||||||
|
q = q.Where("(season_num > 0 OR episode_num > 0)")
|
||||||
|
} else {
|
||||||
|
q = q.Where("season_num = 0 AND episode_num = 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []model.Media
|
||||||
|
if err := q.Order("rating desc, updated_at desc").Limit(similarCandidateLimit).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// compatibleLibraryIDs 返回与源条目同类型的库 ID(可能包含源库自身)。
|
||||||
|
// 查不到类型时退回源库,保证补齐阶段不会跨类型乱推。
|
||||||
|
func (s *MediaDiscoveryService) compatibleLibraryIDs(ctx context.Context, source *model.Media) ([]string, error) {
|
||||||
|
if s.repo.Library == nil {
|
||||||
|
return []string{source.LibraryID}, nil
|
||||||
|
}
|
||||||
|
libs, err := s.repo.Library.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sourceType := ""
|
||||||
|
for _, lib := range libs {
|
||||||
|
if lib.ID == source.LibraryID {
|
||||||
|
sourceType = strings.ToLower(strings.TrimSpace(lib.Type))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sourceType == "" {
|
||||||
|
return []string{source.LibraryID}, nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(libs))
|
||||||
|
for _, lib := range libs {
|
||||||
|
if strings.ToLower(strings.TrimSpace(lib.Type)) == sourceType {
|
||||||
|
out = append(out, lib.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rankSimilar 按相似度排序并截断。
|
||||||
|
func rankSimilar(source *model.Media, pool []model.Media, limit int) []model.Media {
|
||||||
|
if len(pool) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sourceGenres := genreSet(source.Genres)
|
||||||
|
sourceKey := seriesGroupKey(source)
|
||||||
|
|
||||||
|
type scored struct {
|
||||||
|
media model.Media
|
||||||
|
score float64
|
||||||
|
}
|
||||||
|
scoredRows := make([]scored, 0, len(pool))
|
||||||
|
seen := make(map[string]bool, len(pool))
|
||||||
|
for _, candidate := range pool {
|
||||||
|
if candidate.ID == source.ID || seen[candidate.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 同剧其它集不参与:「相似」不是在推荐本剧的下一集。
|
||||||
|
if key := seriesGroupKey(&candidate); key != "" && key == sourceKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[candidate.ID] = true
|
||||||
|
scoredRows = append(scoredRows, scored{
|
||||||
|
media: candidate,
|
||||||
|
score: similarScore(source, &candidate, sourceGenres),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.SliceStable(scoredRows, func(i, j int) bool {
|
||||||
|
if scoredRows[i].score != scoredRows[j].score {
|
||||||
|
return scoredRows[i].score > scoredRows[j].score
|
||||||
|
}
|
||||||
|
if scoredRows[i].media.Rating != scoredRows[j].media.Rating {
|
||||||
|
return scoredRows[i].media.Rating > scoredRows[j].media.Rating
|
||||||
|
}
|
||||||
|
return scoredRows[i].media.Title < scoredRows[j].media.Title
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(scoredRows) > limit {
|
||||||
|
scoredRows = scoredRows[:limit]
|
||||||
|
}
|
||||||
|
out := make([]model.Media, 0, len(scoredRows))
|
||||||
|
for _, row := range scoredRows {
|
||||||
|
out = append(out, row.media)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// similarScore 计算单个候选的相似度。
|
||||||
|
func similarScore(source, candidate *model.Media, sourceGenres map[string]struct{}) float64 {
|
||||||
|
score := float64(genreOverlap(sourceGenres, genreSet(candidate.Genres))) * 10
|
||||||
|
|
||||||
|
if source.Year > 0 && candidate.Year > 0 {
|
||||||
|
diff := source.Year - candidate.Year
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
if diff <= 5 {
|
||||||
|
score += float64(5 - diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if source.Rating > 0 && candidate.Rating > 0 {
|
||||||
|
diff := float64(source.Rating - candidate.Rating)
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
// 评分差 2 分以内才给分,最多 3 分。
|
||||||
|
if diff < 2 {
|
||||||
|
score += 3 * (2 - diff) / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedEpisode 插入一集,并把播放历史指向 `watched`(nil 表示没有历史)。
|
||||||
|
func seedEpisode(
|
||||||
|
t *testing.T,
|
||||||
|
repos *repository.Container,
|
||||||
|
libID, seriesID, title string,
|
||||||
|
season, episode int,
|
||||||
|
watchedAt *time.Time,
|
||||||
|
completed bool,
|
||||||
|
) *model.Media {
|
||||||
|
t.Helper()
|
||||||
|
m := &model.Media{
|
||||||
|
LibraryID: libID,
|
||||||
|
SeriesID: seriesID,
|
||||||
|
Title: title,
|
||||||
|
SeasonNum: season,
|
||||||
|
EpisodeNum: episode,
|
||||||
|
Path: "/media/" + seriesID + "/S" + strconv.Itoa(season) + "E" + strconv.Itoa(episode) + ".mkv",
|
||||||
|
}
|
||||||
|
if err := repos.DB.WithContext(context.Background()).Create(m).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if watchedAt != nil || completed {
|
||||||
|
// 标记「已看完」也会产生一条历史行,因此 completed 为真时同样要写历史,
|
||||||
|
// 否则夹具与真实数据不一致(真实库里已看完一定有行)。
|
||||||
|
watched := time.Now().Add(-time.Hour)
|
||||||
|
if watchedAt != nil {
|
||||||
|
watched = *watchedAt
|
||||||
|
}
|
||||||
|
h := &model.PlaybackHistory{
|
||||||
|
UserID: "user-1",
|
||||||
|
MediaID: m.ID,
|
||||||
|
PositionMs: 1000,
|
||||||
|
DurationMs: 2000,
|
||||||
|
WatchedAt: watched,
|
||||||
|
Completed: completed,
|
||||||
|
}
|
||||||
|
if err := repos.DB.WithContext(context.Background()).Create(h).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextUpIDs(t *testing.T, svc *MediaDiscoveryService, visibility MediaVisibility) []string {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := svc.NextUpCandidates(context.Background(), "user-1", 20, visibility)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, episodeLabel(row))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// episodeLabel 把一集渲染成 "S1E2",方便断言。
|
||||||
|
func episodeLabel(m model.Media) string {
|
||||||
|
return "S" + strconv.Itoa(m.SeasonNum) + "E" + strconv.Itoa(m.EpisodeNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextUpPicksNextEpisode(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
|
watchedAt := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, false)
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, false)
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if len(got) != 1 || got[0] != "S1E2" {
|
||||||
|
t.Fatalf("next up = %v, want [S1E2]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 电影不进 NextUp:NextUp 的语义是「下一集」,电影由 Resume 接口负责。
|
||||||
|
func TestNextUpSkipsMovies(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "movie")
|
||||||
|
watchedAt := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
seedEpisode(t, repos, libID, "", "电影", 0, 0, &watchedAt, false)
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
if got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true}); len(got) != 0 {
|
||||||
|
t.Fatalf("next up = %v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一部剧有多条未看完历史时,只能出一条,且指向最靠后的已看集的下一集。
|
||||||
|
func TestNextUpOneEntryPerSeries(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
|
older := time.Now().Add(-48 * time.Hour)
|
||||||
|
newer := time.Now().Add(-2 * time.Hour)
|
||||||
|
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &older, false)
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, &newer, false)
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if len(got) != 1 || got[0] != "S1E3" {
|
||||||
|
t.Fatalf("next up = %v, want [S1E3]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跨季时下一集应是下一季的第一集。
|
||||||
|
func TestNextUpCrossesSeason(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
|
watchedAt := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 12, &watchedAt, false)
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 2, 1, nil, false)
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 2, 2, nil, false)
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if len(got) != 1 || got[0] != "S2E1" {
|
||||||
|
t.Fatalf("next up = %v, want [S2E1]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已标记看完的下一集要跳过。
|
||||||
|
func TestNextUpSkipsCompletedEpisode(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
|
watchedAt := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, false)
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, true) // 已看完
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if len(got) != 1 || got[0] != "S1E3" {
|
||||||
|
t.Fatalf("next up = %v, want [S1E3]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追到最后一集时没有下一集,结果为空而不是重复返回最后一集。
|
||||||
|
func TestNextUpEmptyAtSeriesEnd(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
|
watchedAt := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, &watchedAt, false)
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
if got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true}); len(got) != 0 {
|
||||||
|
t.Fatalf("next up = %v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不可见媒体库里的下一集不能被推荐出去。
|
||||||
|
func TestNextUpRespectsVisibility(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
visibleLib := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
|
hiddenLib := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
|
watchedAt := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
seedEpisode(t, repos, visibleLib, "series-1", "剧一", 1, 1, &watchedAt, false)
|
||||||
|
seedEpisode(t, repos, hiddenLib, "series-1", "剧一", 1, 2, nil, false)
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true, AllowedLibraryIDs: []string{visibleLib}})
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("next up = %v, want empty (hidden library)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一集看完(completed=true)后,下一部剧仍应出现在 NextUp 中,
|
||||||
|
// 而不是因为没有 completed=false 的历史而消失(bug 1 回归测试)。
|
||||||
|
func TestNextUpAfterCompletedEpisode(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
|
watchedAt := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
// S1E1 已看完,S1E2 尚未开始。
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, true)
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, false)
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if len(got) != 1 || got[0] != "S1E2" {
|
||||||
|
t.Fatalf("next up after completed S1E1 = %v, want [S1E2]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整部剧看完(所有集都 completed=true)时不应出现在 NextUp(没有下一集)。
|
||||||
|
func TestNextUpSeriesFullyWatchedIsEmpty(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
|
watchedAt := time.Now().Add(-time.Hour)
|
||||||
|
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, nil, true)
|
||||||
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, &watchedAt, true) // 最近看完的最后一集
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
if got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true}); len(got) != 0 {
|
||||||
|
t.Fatalf("next up for fully-watched series = %v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多部剧时按最近观看时间排序。
|
||||||
|
func TestNextUpOrdersByRecency(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
|
older := time.Now().Add(-24 * time.Hour)
|
||||||
|
newer := time.Now().Add(-1 * time.Hour)
|
||||||
|
|
||||||
|
seedEpisode(t, repos, libID, "series-a", "剧A", 1, 1, &older, false)
|
||||||
|
seedEpisode(t, repos, libID, "series-a", "剧A", 1, 2, nil, false)
|
||||||
|
seedEpisode(t, repos, libID, "series-b", "剧B", 1, 1, &newer, false)
|
||||||
|
seedEpisode(t, repos, libID, "series-b", "剧B", 1, 2, nil, false)
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
rows, err := svc.NextUpCandidates(context.Background(), "user-1", 20, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("rows = %d, want 2", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].SeriesID != "series-b" {
|
||||||
|
t.Fatalf("first row series = %q, want series-b (most recently watched)", rows[0].SeriesID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedSimilarMedia(t *testing.T, repos *repository.Container, libID, title, genres string, year int, rating float32) *model.Media {
|
||||||
|
t.Helper()
|
||||||
|
m := &model.Media{
|
||||||
|
LibraryID: libID,
|
||||||
|
Title: title,
|
||||||
|
Genres: genres,
|
||||||
|
Year: year,
|
||||||
|
Rating: rating,
|
||||||
|
Path: "/media/" + libID + "/" + title + ".mkv",
|
||||||
|
}
|
||||||
|
if err := repos.DB.WithContext(context.Background()).Create(m).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func similarTitles(t *testing.T, svc *MediaDiscoveryService, sourceID string, visibility MediaVisibility) []string {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := svc.SimilarCandidates(context.Background(), sourceID, 12, visibility)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, row.Title)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsTitle(items []string, want string) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if item == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相似推荐必须排除自己,也要排除同剧的其他集(否则详情页会推荐本剧的其它集)。
|
||||||
|
func TestSimilarExcludesSelfAndSameSeries(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
|
|
||||||
|
source := seedSimilarMedia(t, repos, libID, "剧一 E01", "Action", 2020, 8)
|
||||||
|
source.SeriesID = "series-1"
|
||||||
|
source.SeasonNum, source.EpisodeNum = 1, 1
|
||||||
|
if err := repos.DB.WithContext(context.Background()).Save(source).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sibling := seedSimilarMedia(t, repos, libID, "剧一 E02", "Action", 2020, 8)
|
||||||
|
sibling.SeriesID = "series-1"
|
||||||
|
sibling.SeasonNum, sibling.EpisodeNum = 1, 2
|
||||||
|
if err := repos.DB.WithContext(context.Background()).Save(sibling).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同库另一部剧的第 1 集:这才是剧集详情页该推荐的内容。
|
||||||
|
other := seedSimilarMedia(t, repos, libID, "另一部动作剧", "Action", 2021, 7)
|
||||||
|
other.SeriesID = "series-2"
|
||||||
|
other.SeasonNum, other.EpisodeNum = 1, 1
|
||||||
|
if err := repos.DB.WithContext(context.Background()).Save(other).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
got := similarTitles(t, svc, source.ID, MediaVisibility{IncludeNSFW: true})
|
||||||
|
|
||||||
|
if containsTitle(got, "剧一 E01") {
|
||||||
|
t.Fatalf("result must exclude the source itself: %v", got)
|
||||||
|
}
|
||||||
|
if containsTitle(got, "剧一 E02") {
|
||||||
|
t.Fatalf("result must exclude other episodes of the same series: %v", got)
|
||||||
|
}
|
||||||
|
if !containsTitle(got, "另一部动作剧") {
|
||||||
|
t.Fatalf("result = %v, want it to contain 另一部动作剧", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类型重合度高的条目要排在前面。
|
||||||
|
func TestSimilarPrefersGenreOverlap(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "movie")
|
||||||
|
|
||||||
|
source := seedSimilarMedia(t, repos, libID, "源片", "Action,Adventure", 2010, 7)
|
||||||
|
seedSimilarMedia(t, repos, libID, "同类型", "Action,Adventure", 2010, 7)
|
||||||
|
seedSimilarMedia(t, repos, libID, "弱相关", "Comedy", 2010, 7)
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
got := similarTitles(t, svc, source.ID, MediaVisibility{IncludeNSFW: true})
|
||||||
|
|
||||||
|
if len(got) < 2 {
|
||||||
|
t.Fatalf("result = %v, want at least 2 entries", got)
|
||||||
|
}
|
||||||
|
if got[0] != "同类型" {
|
||||||
|
t.Fatalf("result = %v, want 同类型 ranked first", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不可见媒体库的条目不能被推荐。
|
||||||
|
func TestSimilarRespectsVisibility(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
sourceLib := seedDiscoveryLibrary(t, repos, "movie")
|
||||||
|
hiddenLib := seedDiscoveryLibrary(t, repos, "movie")
|
||||||
|
|
||||||
|
source := seedSimilarMedia(t, repos, sourceLib, "源片", "Action", 2010, 7)
|
||||||
|
seedSimilarMedia(t, repos, hiddenLib, "隐藏片", "Action", 2010, 7)
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
got := similarTitles(t, svc, source.ID, MediaVisibility{IncludeNSFW: true, AllowedLibraryIDs: []string{sourceLib}})
|
||||||
|
|
||||||
|
if containsTitle(got, "隐藏片") {
|
||||||
|
t.Fatalf("hidden library leaked into similar: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 源条目不可见或不存在时返回空,不报错:客户端不该因此看到 500。
|
||||||
|
func TestSimilarUnknownSourceReturnsEmpty(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
|
||||||
|
rows, err := svc.SimilarCandidates(context.Background(), "missing", 12, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Fatalf("rows = %v, want empty", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit 生效,且不返回重复条目。
|
||||||
|
func TestSimilarHonoursLimit(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "movie")
|
||||||
|
source := seedSimilarMedia(t, repos, libID, "源片", "Action", 2010, 7)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
seedSimilarMedia(t, repos, libID, "候选"+string(rune('A'+i)), "Action", 2010, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
rows, err := svc.SimilarCandidates(context.Background(), source.ID, 3, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 3 {
|
||||||
|
t.Fatalf("rows = %d, want 3", len(rows))
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, row := range rows {
|
||||||
|
if seen[row.ID] {
|
||||||
|
t.Fatalf("duplicate row %q in result", row.Title)
|
||||||
|
}
|
||||||
|
seen[row.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newDiscoveryTestDB 建好内存库并返回 repository 容器。
|
||||||
|
func newDiscoveryTestDB(t *testing.T) *repository.Container {
|
||||||
|
t.Helper()
|
||||||
|
return repository.New(newServiceTestDB(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedDiscoveryLibrary(t *testing.T, repos *repository.Container, typ string) string {
|
||||||
|
t.Helper()
|
||||||
|
lib := &model.Library{Name: "库-" + typ, Path: "/media/" + typ, Type: typ, Enabled: true}
|
||||||
|
if err := repos.Library.Create(context.Background(), lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return lib.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedDiscoveryMedia(t *testing.T, repos *repository.Container, m *model.Media) {
|
||||||
|
t.Helper()
|
||||||
|
if err := repos.DB.WithContext(context.Background()).Create(m).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findGenre(genres []GenreCount, name string) int {
|
||||||
|
for _, g := range genres {
|
||||||
|
if g.Name == name {
|
||||||
|
return g.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregateGenresSplitsAndCounts(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "movie")
|
||||||
|
|
||||||
|
seedDiscoveryMedia(t, repos, &model.Media{
|
||||||
|
LibraryID: libID, Title: "A", Path: "/media/movie/a.mkv", Genres: "Action,Drama",
|
||||||
|
})
|
||||||
|
seedDiscoveryMedia(t, repos, &model.Media{
|
||||||
|
LibraryID: libID, Title: "B", Path: "/media/movie/b.mkv", Genres: "action",
|
||||||
|
})
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
genres, err := svc.AggregateGenres(context.Background(), MediaVisibility{IncludeNSFW: true}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大小写不同视为同一类型,展示名保留首次出现的写法。
|
||||||
|
if got := findGenre(genres, "Action"); got != 2 {
|
||||||
|
t.Fatalf("Action count = %d, want 2 (genres=%+v)", got, genres)
|
||||||
|
}
|
||||||
|
if got := findGenre(genres, "Drama"); got != 1 {
|
||||||
|
t.Fatalf("Drama count = %d, want 1 (genres=%+v)", got, genres)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中文全角逗号在刮削结果里同样常见,必须一并切分。
|
||||||
|
func TestAggregateGenresHandlesFullWidthComma(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
libID := seedDiscoveryLibrary(t, repos, "movie")
|
||||||
|
seedDiscoveryMedia(t, repos, &model.Media{
|
||||||
|
LibraryID: libID, Title: "A", Path: "/media/movie/a.mkv", Genres: "科幻,悬疑",
|
||||||
|
})
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
genres, err := svc.AggregateGenres(context.Background(), MediaVisibility{IncludeNSFW: true}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if findGenre(genres, "科幻") != 1 || findGenre(genres, "悬疑") != 1 {
|
||||||
|
t.Fatalf("genres = %+v, want 科幻:1 and 悬疑:1", genres)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryID 非空时只统计该库,供媒体库页面的筛选项使用。
|
||||||
|
func TestAggregateGenresScopedToLibrary(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
movieLib := seedDiscoveryLibrary(t, repos, "movie")
|
||||||
|
tvLib := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
|
|
||||||
|
seedDiscoveryMedia(t, repos, &model.Media{
|
||||||
|
LibraryID: movieLib, Title: "A", Path: "/media/movie/a.mkv", Genres: "Action",
|
||||||
|
})
|
||||||
|
seedDiscoveryMedia(t, repos, &model.Media{
|
||||||
|
LibraryID: tvLib, Title: "B", Path: "/media/tv/b.mkv", Genres: "Comedy",
|
||||||
|
})
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
genres, err := svc.AggregateGenres(context.Background(), MediaVisibility{IncludeNSFW: true}, movieLib)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if findGenre(genres, "Comedy") != -1 {
|
||||||
|
t.Fatalf("Comedy must not appear when scoped to the movie library: %+v", genres)
|
||||||
|
}
|
||||||
|
if findGenre(genres, "Action") != 1 {
|
||||||
|
t.Fatalf("Action count = %d, want 1", findGenre(genres, "Action"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可见性过滤必须生效:不可见的库不应泄漏类型统计。
|
||||||
|
func TestAggregateGenresRespectsVisibility(t *testing.T) {
|
||||||
|
repos := newDiscoveryTestDB(t)
|
||||||
|
allowedLib := seedDiscoveryLibrary(t, repos, "movie")
|
||||||
|
otherLib := seedDiscoveryLibrary(t, repos, "movie")
|
||||||
|
|
||||||
|
seedDiscoveryMedia(t, repos, &model.Media{
|
||||||
|
LibraryID: allowedLib, Title: "A", Path: "/media/a/a.mkv", Genres: "Action",
|
||||||
|
})
|
||||||
|
seedDiscoveryMedia(t, repos, &model.Media{
|
||||||
|
LibraryID: otherLib, Title: "B", Path: "/media/b/b.mkv", Genres: "Secret",
|
||||||
|
})
|
||||||
|
|
||||||
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
genres, err := svc.AggregateGenres(context.Background(),
|
||||||
|
MediaVisibility{IncludeNSFW: true, AllowedLibraryIDs: []string{allowedLib}}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if findGenre(genres, "Secret") != -1 {
|
||||||
|
t.Fatalf("hidden library leaked into genres: %+v", genres)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/rand"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LibraryFacets 是媒体库筛选面板需要的元数据:可选类型与年份区间。
|
||||||
|
type LibraryFacets struct {
|
||||||
|
Genres []GenreCount `json:"genres"`
|
||||||
|
YearMin int `json:"year_min"`
|
||||||
|
YearMax int `json:"year_max"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryFilterFrom 组装「可见性 + 用户筛选」的最终仓储条件,
|
||||||
|
// 与列表查询保持完全一致的语义。
|
||||||
|
func libraryFilterFrom(visibility MediaVisibility, filters MediaListFilters) repository.MediaQueryFilter {
|
||||||
|
return filters.apply(repository.MediaQueryFilter{
|
||||||
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||||
|
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryFacets 返回某个库的筛选项。类型统计复用 MediaDiscoveryService,
|
||||||
|
// 避免在筛选面板与 Emby /Genres 之间出现两套口径。
|
||||||
|
//
|
||||||
|
// 作用范围必须与列表查询一致:列表用的是「合并网盘库」后的库 ID 集合,因此这
|
||||||
|
// 里也把可见性收窄到同一集合,否则筛选项会漏掉列表里真实存在的条目。
|
||||||
|
func (s *MediaService) LibraryFacets(
|
||||||
|
ctx context.Context,
|
||||||
|
libraryID string,
|
||||||
|
visibility MediaVisibility,
|
||||||
|
discovery *MediaDiscoveryService,
|
||||||
|
) (LibraryFacets, error) {
|
||||||
|
var facets LibraryFacets
|
||||||
|
if s == nil || s.repo == nil || s.repo.Media == nil {
|
||||||
|
return facets, nil
|
||||||
|
}
|
||||||
|
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||||
|
merged, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return facets, err
|
||||||
|
}
|
||||||
|
scoped, ok := scopeVisibilityToLibraries(visibility, merged)
|
||||||
|
if !ok {
|
||||||
|
facets.Genres = []GenreCount{}
|
||||||
|
return facets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
yearMin, yearMax, err := s.repo.Media.YearRange(ctx, libraryFilterFrom(scoped, MediaListFilters{}))
|
||||||
|
if err != nil {
|
||||||
|
return facets, err
|
||||||
|
}
|
||||||
|
facets.YearMin = yearMin
|
||||||
|
facets.YearMax = yearMax
|
||||||
|
|
||||||
|
if discovery != nil {
|
||||||
|
genres, err := discovery.AggregateGenres(ctx, scoped, "")
|
||||||
|
if err != nil {
|
||||||
|
return facets, err
|
||||||
|
}
|
||||||
|
facets.Genres = genres
|
||||||
|
}
|
||||||
|
if facets.Genres == nil {
|
||||||
|
facets.Genres = []GenreCount{}
|
||||||
|
}
|
||||||
|
return facets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeVisibilityToLibraries 把可见性收窄到给定库集合。
|
||||||
|
//
|
||||||
|
// 返回 ok=false 表示「这些库与用户的可见性没有交集」——此时必须返回空结果,
|
||||||
|
// 而不是退化成不过滤,否则受限用户会看到别人的库。
|
||||||
|
func scopeVisibilityToLibraries(visibility MediaVisibility, libraryIDs []string) (MediaVisibility, bool) {
|
||||||
|
allowed := make(map[string]struct{}, len(visibility.AllowedLibraryIDs))
|
||||||
|
for _, id := range visibility.AllowedLibraryIDs {
|
||||||
|
allowed[id] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(libraryIDs))
|
||||||
|
for _, id := range libraryIDs {
|
||||||
|
if len(allowed) > 0 {
|
||||||
|
if _, ok := allowed[id]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return visibility, false
|
||||||
|
}
|
||||||
|
visibility.AllowedLibraryIDs = out
|
||||||
|
return visibility, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RandomMedia 在「可见性 + 筛选」的结果集里随机取一条。
|
||||||
|
//
|
||||||
|
// 实现是「先 COUNT 再随机 offset」的两段查询:SQLite 没有 TABLESAMPLE,
|
||||||
|
// ORDER BY RANDOM() 又会对整库排序(大库上会拖垮磁盘),因此用等价的
|
||||||
|
// 偏移量取法,且两种数据库方言完全一致。
|
||||||
|
//
|
||||||
|
// 返回 (nil, nil) 表示结果集为空,由调用方决定响应码。
|
||||||
|
func (s *MediaService) RandomMedia(
|
||||||
|
ctx context.Context,
|
||||||
|
libraryID string,
|
||||||
|
visibility MediaVisibility,
|
||||||
|
filters MediaListFilters,
|
||||||
|
) (*model.Media, error) {
|
||||||
|
if s == nil || s.repo == nil || s.repo.Media == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||||
|
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
filter := libraryFilterFrom(visibility, filters)
|
||||||
|
|
||||||
|
_, total, err := s.repo.Media.ListByLibrariesFiltered(ctx, libraryIDs, 0, 1, filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if total <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超大结果集直接对全部记录随机 OFFSET 会让数据库扫描海量行(SQLite 逐行计数)。
|
||||||
|
// 当总量超过阈值时,收窄到按更新时间最新的前 N 条(列表排序首列是
|
||||||
|
// release_date / updated_at,前 randomPoolCap 行即为"最新"子集),
|
||||||
|
// 偏移量取其中随机位置,兼顾性能与覆盖面。
|
||||||
|
const randomPoolCap = 5000
|
||||||
|
effectiveTotal := total
|
||||||
|
if effectiveTotal > randomPoolCap {
|
||||||
|
effectiveTotal = randomPoolCap
|
||||||
|
}
|
||||||
|
offset := 0
|
||||||
|
if effectiveTotal > 1 {
|
||||||
|
offset = rand.Intn(int(effectiveTotal))
|
||||||
|
}
|
||||||
|
items, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, offset, 1, filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
s.attachLibraryMetadata(ctx, items)
|
||||||
|
return &items[0], nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 系列路径的筛选在内存里复现 SQL 语义,必须与仓储侧一致,否则会出现
|
||||||
|
// 「电影库能筛、剧集库筛不动」的行为差异。
|
||||||
|
func TestMediaRowMatchesFilters(t *testing.T) {
|
||||||
|
row := &model.Media{
|
||||||
|
Base: model.Base{ID: "m1"},
|
||||||
|
Title: "片",
|
||||||
|
Genres: "Action,Drama",
|
||||||
|
Year: 2015,
|
||||||
|
Rating: 7.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
filters MediaListFilters
|
||||||
|
completed map[string]bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "no filters", filters: MediaListFilters{}, want: true},
|
||||||
|
{name: "genre hit", filters: MediaListFilters{Genres: []string{"Action"}}, want: true},
|
||||||
|
{name: "genre miss", filters: MediaListFilters{Genres: []string{"Comedy"}}, want: false},
|
||||||
|
{name: "year in range", filters: MediaListFilters{YearMin: 2010, YearMax: 2020}, want: true},
|
||||||
|
{name: "year below min", filters: MediaListFilters{YearMin: 2016}, want: false},
|
||||||
|
{name: "year above max", filters: MediaListFilters{YearMax: 2014}, want: false},
|
||||||
|
{name: "rating ok", filters: MediaListFilters{RatingMin: 7}, want: true},
|
||||||
|
{name: "rating too high", filters: MediaListFilters{RatingMin: 8}, want: false},
|
||||||
|
{name: "unwatched passes", filters: MediaListFilters{Unwatched: true}, want: true},
|
||||||
|
{
|
||||||
|
name: "unwatched excludes completed",
|
||||||
|
filters: MediaListFilters{Unwatched: true},
|
||||||
|
completed: map[string]bool{"m1": true},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := mediaRowMatchesFilters(row, tc.filters, tc.completed); got != tc.want {
|
||||||
|
t.Fatalf("matched = %t, want %t", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多词类型(如 "Science Fiction")在内存筛选中必须整词命中。
|
||||||
|
func TestMediaRowMatchesFiltersMultiWordGenre(t *testing.T) {
|
||||||
|
row := &model.Media{
|
||||||
|
Base: model.Base{ID: "m2"},
|
||||||
|
Title: "科幻片",
|
||||||
|
Genres: "Science Fiction,Drama",
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
filters MediaListFilters
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "multi-word hit", filters: MediaListFilters{Genres: []string{"Science Fiction"}}, want: true},
|
||||||
|
{name: "partial word miss", filters: MediaListFilters{Genres: []string{"Science"}}, want: false},
|
||||||
|
{name: "case insensitive hit", filters: MediaListFilters{Genres: []string{"science fiction"}}, want: true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := mediaRowMatchesFilters(row, tc.filters, nil); got != tc.want {
|
||||||
|
t.Fatalf("matched = %t, want %t", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无筛选时 empty() 为真,调用方据此走带缓存的原路径。
|
||||||
|
func TestMediaListFiltersEmpty(t *testing.T) {
|
||||||
|
if !(MediaListFilters{}).empty() {
|
||||||
|
t.Fatal("zero filters must be reported as empty")
|
||||||
|
}
|
||||||
|
if (MediaListFilters{Genres: []string{"Action"}}).empty() {
|
||||||
|
t.Fatal("genre filter must not be reported as empty")
|
||||||
|
}
|
||||||
|
if (MediaListFilters{Unwatched: true}).empty() {
|
||||||
|
t.Fatal("unwatched filter must not be reported as empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,63 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ListMedia paginates media items inside a library.
|
// ListMedia paginates media items inside a library.
|
||||||
|
// MediaListFilters 是列表接口的可选筛选条件(来自查询串或库内筛选面板)。
|
||||||
|
//
|
||||||
|
// 与 MediaVisibility 分开:可见性是权限约束(服务端强制),这些是用户主动
|
||||||
|
// 选择的浏览条件,两者在 SQL 层是「与」关系。
|
||||||
|
type MediaListFilters struct {
|
||||||
|
Genres []string
|
||||||
|
YearMin int
|
||||||
|
YearMax int
|
||||||
|
RatingMin float64
|
||||||
|
Unwatched bool
|
||||||
|
// UserID 是「未观看」判定所需的账号;为空时 Unwatched 被忽略。
|
||||||
|
UserID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// empty 报告是否没有任何筛选条件。调用方据此走缓存友好的默认路径。
|
||||||
|
func (f MediaListFilters) empty() bool {
|
||||||
|
return len(f.Genres) == 0 && f.YearMin <= 0 && f.YearMax <= 0 &&
|
||||||
|
f.RatingMin <= 0 && !f.Unwatched
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply 把筛选条件叠加到仓储过滤条件上。
|
||||||
|
func (f MediaListFilters) apply(filter repository.MediaQueryFilter) repository.MediaQueryFilter {
|
||||||
|
if len(f.Genres) > 0 {
|
||||||
|
filter.Genres = f.Genres
|
||||||
|
}
|
||||||
|
if f.YearMin > 0 {
|
||||||
|
filter.YearMin = f.YearMin
|
||||||
|
}
|
||||||
|
if f.YearMax > 0 {
|
||||||
|
filter.YearMax = f.YearMax
|
||||||
|
}
|
||||||
|
if f.RatingMin > 0 {
|
||||||
|
filter.RatingMin = f.RatingMin
|
||||||
|
}
|
||||||
|
if f.Unwatched && strings.TrimSpace(f.UserID) != "" {
|
||||||
|
filter.UnwatchedOnly = true
|
||||||
|
filter.UnwatchedUserID = f.UserID
|
||||||
|
}
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaService) ListMedia(ctx context.Context, libraryID string, page, pageSize int) ([]model.Media, int64, error) {
|
func (s *MediaService) ListMedia(ctx context.Context, libraryID string, page, pageSize int) ([]model.Media, int64, error) {
|
||||||
return s.ListMediaVisible(ctx, libraryID, page, pageSize, MediaVisibility{IncludeNSFW: true})
|
return s.ListMediaVisible(ctx, libraryID, page, pageSize, MediaVisibility{IncludeNSFW: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, page, pageSize int, visibility MediaVisibility) ([]model.Media, int64, error) {
|
func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, page, pageSize int, visibility MediaVisibility) ([]model.Media, int64, error) {
|
||||||
|
return s.ListMediaVisibleFiltered(ctx, libraryID, page, pageSize, visibility, MediaListFilters{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMediaVisibleFiltered 在可见性之上叠加用户筛选条件。
|
||||||
|
func (s *MediaService) ListMediaVisibleFiltered(
|
||||||
|
ctx context.Context,
|
||||||
|
libraryID string,
|
||||||
|
page, pageSize int,
|
||||||
|
visibility MediaVisibility,
|
||||||
|
filters MediaListFilters,
|
||||||
|
) ([]model.Media, int64, error) {
|
||||||
if pageSize <= 0 {
|
if pageSize <= 0 {
|
||||||
pageSize = 50
|
pageSize = 50
|
||||||
}
|
}
|
||||||
@@ -31,11 +83,11 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
filter := repository.MediaQueryFilter{
|
filter := filters.apply(repository.MediaQueryFilter{
|
||||||
IncludeNSFW: visibility.IncludeNSFW,
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||||
}
|
})
|
||||||
cacheKey := s.mediaListCacheKey(libraryID, libraryIDs, page, pageSize, filter)
|
cacheKey := s.mediaListCacheKey(libraryID, libraryIDs, page, pageSize, filter)
|
||||||
var cached mediaListCacheValue
|
var cached mediaListCacheValue
|
||||||
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
|
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||||
@@ -54,7 +106,6 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaService) ListMediaVisibleGrouped(ctx context.Context, libraryID string, page, pageSize int, visibility MediaVisibility) ([]MediaItem, int64, error) {
|
func (s *MediaService) ListMediaVisibleGrouped(ctx context.Context, libraryID string, page, pageSize int, visibility MediaVisibility) ([]MediaItem, int64, error) {
|
||||||
page, pageSize = normalizeGroupedMediaPage(page, pageSize)
|
|
||||||
grouped, err := s.GroupedMediaVisible(ctx, libraryID, visibility)
|
grouped, err := s.GroupedMediaVisible(ctx, libraryID, visibility)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@@ -65,16 +116,29 @@ func (s *MediaService) ListMediaVisibleGrouped(ctx context.Context, libraryID st
|
|||||||
// GroupedMediaVisible returns the complete version-grouped media list before pagination.
|
// GroupedMediaVisible returns the complete version-grouped media list before pagination.
|
||||||
// The result is cached as an immutable slice; sort/pagination callers must copy it before mutating.
|
// The result is cached as an immutable slice; sort/pagination callers must copy it before mutating.
|
||||||
func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]MediaItem, error) {
|
func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string, visibility MediaVisibility) ([]MediaItem, error) {
|
||||||
|
return s.GroupedMediaVisibleFiltered(ctx, libraryID, visibility, MediaListFilters{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupedMediaVisibleFiltered 在可见性之上叠加用户筛选条件。
|
||||||
|
//
|
||||||
|
// 版本分组的筛选必须作用在原始行上(先筛后分组):否则「按类型筛选」会把
|
||||||
|
// 同一部作品的不同版本拆到不同筛选结果里,出现重复卡片。
|
||||||
|
func (s *MediaService) GroupedMediaVisibleFiltered(
|
||||||
|
ctx context.Context,
|
||||||
|
libraryID string,
|
||||||
|
visibility MediaVisibility,
|
||||||
|
filters MediaListFilters,
|
||||||
|
) ([]MediaItem, error) {
|
||||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||||
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
|
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
filter := repository.MediaQueryFilter{
|
filter := filters.apply(repository.MediaQueryFilter{
|
||||||
IncludeNSFW: visibility.IncludeNSFW,
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||||
}
|
})
|
||||||
itemsCacheKey := s.groupedItemsCacheKey(libraryID, libraryIDs, filter)
|
itemsCacheKey := s.groupedItemsCacheKey(libraryID, libraryIDs, filter)
|
||||||
value, err, _ := s.groupedMediaFlight.Do(itemsCacheKey, func() (any, error) {
|
value, err, _ := s.groupedMediaFlight.Do(itemsCacheKey, func() (any, error) {
|
||||||
if s.cache != nil {
|
if s.cache != nil {
|
||||||
@@ -86,7 +150,7 @@ func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string
|
|||||||
}
|
}
|
||||||
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
items, err := s.listMediaVisibleForGrouping(loadCtx, libraryID, visibility)
|
items, err := s.listMediaVisibleForGrouping(loadCtx, libraryID, visibility, filters)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -105,17 +169,17 @@ func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility) ([]model.Media, error) {
|
func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryID string, visibility MediaVisibility, filters MediaListFilters) ([]model.Media, error) {
|
||||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
||||||
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
|
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
filter := repository.MediaQueryFilter{
|
filter := filters.apply(repository.MediaQueryFilter{
|
||||||
IncludeNSFW: visibility.IncludeNSFW,
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||||
}
|
})
|
||||||
// 版本分组的 URL 分页发生在 Go 进程内,响应里的 total 是分组后的数量,
|
// 版本分组的 URL 分页发生在 Go 进程内,响应里的 total 是分组后的数量,
|
||||||
// 不需要数据库再为原始行做一次 COUNT(*)。全量 COUNT 在超大媒体库上
|
// 不需要数据库再为原始行做一次 COUNT(*)。全量 COUNT 在超大媒体库上
|
||||||
// 会重复扫描整个 library_id 范围,而这里只关心是否存在截断风险。
|
// 会重复扫描整个 library_id 范围,而这里只关心是否存在截断风险。
|
||||||
@@ -133,8 +197,7 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetMedia returns a single media row.
|
// GetMedia returns a single media row.
|
||||||
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) {
|
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) { media, err := s.repo.Media.FindByID(ctx, id)
|
||||||
media, err := s.repo.Media.FindByID(ctx, id)
|
|
||||||
if err != nil || media == nil {
|
if err != nil || media == nil {
|
||||||
return media, err
|
return media, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,98 @@ func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID str
|
|||||||
return cards, total, nil
|
return cards, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListLibrarySeriesCardsFiltered 在系列卡片上应用筛选。
|
||||||
|
//
|
||||||
|
// 语义:先对原始剧集行做筛选,再分组 —— 于是「剧里任意一集命中条件」即可保留
|
||||||
|
// 该剧。这比只筛代表行更符合直觉(用户勾选「动作」是想要动作剧,而不是
|
||||||
|
// 「第一集恰好是动作的剧」)。
|
||||||
|
//
|
||||||
|
// 无筛选时直接走带缓存的原路径,避免平白多一次全库分组。
|
||||||
|
func (s *MediaService) ListLibrarySeriesCardsFiltered(
|
||||||
|
ctx context.Context,
|
||||||
|
libraryID string,
|
||||||
|
visibility MediaVisibility,
|
||||||
|
filters MediaListFilters,
|
||||||
|
) ([]SeriesCard, int64, error) {
|
||||||
|
if filters.empty() {
|
||||||
|
return s.ListLibrarySeriesCards(ctx, libraryID, visibility)
|
||||||
|
}
|
||||||
|
rows, err := s.libraryRowsWithIndex(ctx, libraryID, visibility)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
completed := s.completedMediaIDSet(ctx, filters)
|
||||||
|
kept := make([]model.Media, 0, len(rows.Rows))
|
||||||
|
for _, row := range rows.Rows {
|
||||||
|
if !mediaRowMatchesFilters(&row, filters, completed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, row)
|
||||||
|
}
|
||||||
|
cards := groupMediaSeriesCards(kept)
|
||||||
|
if cards == nil {
|
||||||
|
cards = []SeriesCard{}
|
||||||
|
}
|
||||||
|
return cards, int64(len(cards)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaRowMatchesFilters 在内存里复现 SQL 层的筛选语义。
|
||||||
|
//
|
||||||
|
// 系列路径无法直接复用仓储过滤(它基于整库共享缓存),因此这里必须与
|
||||||
|
// applyMediaQueryFilter 保持同一套判定,否则会出现「电影库能筛、剧集库不同」
|
||||||
|
// 的行为差异。
|
||||||
|
func mediaRowMatchesFilters(row *model.Media, filters MediaListFilters, completed map[string]bool) bool {
|
||||||
|
if row == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if filters.YearMin > 0 && row.Year < filters.YearMin {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if filters.YearMax > 0 && row.Year > filters.YearMax {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if filters.RatingMin > 0 && float64(row.Rating) < filters.RatingMin {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if filters.Unwatched && completed[row.ID] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(filters.Genres) > 0 {
|
||||||
|
rowGenres := genreSet(row.Genres)
|
||||||
|
matched := false
|
||||||
|
for _, want := range filters.Genres {
|
||||||
|
if _, ok := rowGenres[strings.ToLower(strings.TrimSpace(want))]; ok {
|
||||||
|
matched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// completedMediaIDSet 返回该用户已标记看完的媒体 ID 集合(未启用未观看筛选时
|
||||||
|
// 返回 nil,避免无谓查询)。
|
||||||
|
func (s *MediaService) completedMediaIDSet(ctx context.Context, filters MediaListFilters) map[string]bool {
|
||||||
|
userID := strings.TrimSpace(filters.UserID)
|
||||||
|
if !filters.Unwatched || userID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rows []model.PlaybackHistory
|
||||||
|
if err := s.repo.DB.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND completed = ?", userID, true).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]bool, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out[row.MediaID] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
|
func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 24
|
limit = 24
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type SchedulerService struct {
|
|||||||
organizePipeline *OrganizePipelineService
|
organizePipeline *OrganizePipelineService
|
||||||
hub *Hub
|
hub *Hub
|
||||||
tasks *TaskTrackerService
|
tasks *TaskTrackerService
|
||||||
|
expiryWatcher *TelegramExpiryWatcher
|
||||||
cacheDir string
|
cacheDir string
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
|
||||||
@@ -59,6 +60,11 @@ func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService
|
|||||||
s.organizePipeline = pipeline
|
s.organizePipeline = pipeline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetExpiryWatcher 注入账号到期巡检。未注入时(例如测试)该任务不注册。
|
||||||
|
func (s *SchedulerService) SetExpiryWatcher(watcher *TelegramExpiryWatcher) {
|
||||||
|
s.expiryWatcher = watcher
|
||||||
|
}
|
||||||
|
|
||||||
// ImageCachePolicy 是一次图片缓存清理要用的策略(全部为 0 表示不做任何清理)。
|
// ImageCachePolicy 是一次图片缓存清理要用的策略(全部为 0 表示不做任何清理)。
|
||||||
type ImageCachePolicy struct {
|
type ImageCachePolicy struct {
|
||||||
TotalBytes int64
|
TotalBytes int64
|
||||||
@@ -139,6 +145,14 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
|||||||
run: s.jobCleanImageCache,
|
run: s.jobCleanImageCache,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
// 到期提醒只在配置了巡检器时注册,避免测试与未启用通知的部署跑空转任务。
|
||||||
|
if s.expiryWatcher != nil {
|
||||||
|
s.jobs = append(s.jobs, &scheduledJob{
|
||||||
|
name: "telegram_expiry_warning",
|
||||||
|
interval: 24 * time.Hour,
|
||||||
|
run: s.jobTelegramExpiryWarning,
|
||||||
|
})
|
||||||
|
}
|
||||||
for _, j := range s.jobs {
|
for _, j := range s.jobs {
|
||||||
initialDelay := 15 * time.Second
|
initialDelay := 15 * time.Second
|
||||||
if j.name == "library_scan" || j.name == "organize_source" {
|
if j.name == "library_scan" || j.name == "organize_source" {
|
||||||
|
|||||||
@@ -169,6 +169,14 @@ func (s *SchedulerService) organizeSourceInterval(ctx context.Context) time.Dura
|
|||||||
return time.Duration(seconds) * time.Second
|
return time.Duration(seconds) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jobTelegramExpiryWarning 每日巡检即将到期的账号并提醒用户。
|
||||||
|
func (s *SchedulerService) jobTelegramExpiryWarning(ctx context.Context) error {
|
||||||
|
if s.expiryWatcher == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.expiryWatcher.RunOnce(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// jobCleanTranscodeCache deletes HLS artefacts older than 24h.
|
// jobCleanTranscodeCache deletes HLS artefacts older than 24h.
|
||||||
func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
|
func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
|
||||||
if s.cacheDir == "" {
|
if s.cacheDir == "" {
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ type Container struct {
|
|||||||
Token *TokenService
|
Token *TokenService
|
||||||
ApiConfig *ApiConfigService
|
ApiConfig *ApiConfigService
|
||||||
Device *DeviceService
|
Device *DeviceService
|
||||||
|
Telegram *TelegramService
|
||||||
|
TelegramExpiry *TelegramExpiryWatcher
|
||||||
|
Discovery *MediaDiscoveryService
|
||||||
Cache *RuntimeCacheService
|
Cache *RuntimeCacheService
|
||||||
Sessions *SessionTrackerService
|
Sessions *SessionTrackerService
|
||||||
RecognitionWords *RecognitionWordsService
|
RecognitionWords *RecognitionWordsService
|
||||||
@@ -103,6 +106,11 @@ func (c *Container) Boot() {
|
|||||||
// 启动调度器定时任务
|
// 启动调度器定时任务
|
||||||
c.Scheduler.Start(c.stopCtx)
|
c.Scheduler.Start(c.stopCtx)
|
||||||
|
|
||||||
|
// Telegram 通知轮询(未启用或未配置 Token 时直接返回)
|
||||||
|
if c.Telegram != nil {
|
||||||
|
c.Telegram.Start(c.stopCtx)
|
||||||
|
}
|
||||||
|
|
||||||
// 远程 Emby 挂载兼容迁移:清理已删账号的残留挂载;旧账号无挂载时自动全量挂载
|
// 远程 Emby 挂载兼容迁移:清理已删账号的残留挂载;旧账号无挂载时自动全量挂载
|
||||||
if c.EmbyRemote != nil {
|
if c.EmbyRemote != nil {
|
||||||
c.EmbyRemote.CleanupOrphanMounts(c.stopCtx)
|
c.EmbyRemote.CleanupOrphanMounts(c.stopCtx)
|
||||||
|
|||||||
@@ -42,11 +42,22 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository.
|
|||||||
builder.initContentServices()
|
builder.initContentServices()
|
||||||
builder.initAccessAndStorageServices()
|
builder.initAccessAndStorageServices()
|
||||||
builder.initIdentityServices()
|
builder.initIdentityServices()
|
||||||
|
// Telegram 在 initIdentityServices 里构建,这里把失败告警接到任务状态机上。
|
||||||
|
builder.wireTaskNotifications()
|
||||||
builder.initImageProxy()
|
builder.initImageProxy()
|
||||||
builder.attachRuntimeContext()
|
builder.attachRuntimeContext()
|
||||||
return builder.c
|
return builder.c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wireTaskNotifications 把任务失败通知接到 Telegram。必须在 Tasks 与 Telegram
|
||||||
|
// 都已构建之后调用:早于两者其一会静默漏接。
|
||||||
|
func (b *serviceContainerBuilder) wireTaskNotifications() {
|
||||||
|
if b.c.Tasks == nil || b.c.Telegram == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.c.Tasks.SetFailureNotifier(b.c.Telegram.SendToAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
func (b *serviceContainerBuilder) startRealtimeServices() {
|
func (b *serviceContainerBuilder) startRealtimeServices() {
|
||||||
b.c.WSHub = NewHub(b.log)
|
b.c.WSHub = NewHub(b.log)
|
||||||
helper.Go(b.log, "ws.hub", b.c.WSHub.Run)
|
helper.Go(b.log, "ws.hub", b.c.WSHub.Run)
|
||||||
@@ -110,6 +121,9 @@ func (b *serviceContainerBuilder) initContentServices() {
|
|||||||
b.c.DLNA = NewDLNAService(b.log)
|
b.c.DLNA = NewDLNAService(b.log)
|
||||||
b.c.Storage = NewStorageService(b.log, b.repos)
|
b.c.Storage = NewStorageService(b.log, b.repos)
|
||||||
b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos).SetTMDbProvider(b.c.TMDb).SetAdultProvider(b.c.Scraper.adult)
|
b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos).SetTMDbProvider(b.c.TMDb).SetAdultProvider(b.c.Scraper.adult)
|
||||||
|
// 发现类查询(NextUp / Similar / Genres):Emby 兼容层与媒体库筛选共用。
|
||||||
|
b.c.Discovery = NewMediaDiscoveryService(b.log, b.repos)
|
||||||
|
b.c.Emby.SetDiscovery(b.c.Discovery)
|
||||||
b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto).SetRuntimeCache(b.c.Cache)
|
b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto).SetRuntimeCache(b.c.Cache)
|
||||||
b.c.Emby.SetEmbyRemote(b.c.EmbyRemote)
|
b.c.Emby.SetEmbyRemote(b.c.EmbyRemote)
|
||||||
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB)
|
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB)
|
||||||
@@ -161,6 +175,7 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
|||||||
b.c.Database = NewDatabaseAdminService(b.cfg, b.log, b.repos, b.repos.DB)
|
b.c.Database = NewDatabaseAdminService(b.cfg, b.log, b.repos, b.repos.DB)
|
||||||
b.c.Emby.SetRuntimeCache(b.c.Cache)
|
b.c.Emby.SetRuntimeCache(b.c.Cache)
|
||||||
b.c.Emby.SetSubtitleService(b.c.Subtitle)
|
b.c.Emby.SetSubtitleService(b.c.Subtitle)
|
||||||
|
b.c.Emby.SetDiscovery(b.c.Discovery)
|
||||||
b.c.Scheduler = NewSchedulerService(
|
b.c.Scheduler = NewSchedulerService(
|
||||||
b.log, b.repos, b.c.Scan, b.c.Transcoder,
|
b.log, b.repos, b.c.Scan, b.c.Transcoder,
|
||||||
b.c.Organizer, b.c.WSHub, b.cfg.Cache.CacheDir,
|
b.c.Organizer, b.c.WSHub, b.cfg.Cache.CacheDir,
|
||||||
@@ -190,6 +205,15 @@ func (b *serviceContainerBuilder) initIdentityServices() {
|
|||||||
b.c.Sessions = NewSessionTrackerService(b.log)
|
b.c.Sessions = NewSessionTrackerService(b.log)
|
||||||
b.c.Device = NewDeviceService(b.log, b.repos)
|
b.c.Device = NewDeviceService(b.log, b.repos)
|
||||||
b.c.Device.SetSessionTracker(b.c.Sessions)
|
b.c.Device.SetSessionTracker(b.c.Sessions)
|
||||||
|
// Telegram 通知:未启用时 Start 不会占用 goroutine,SendToUser 静默跳过。
|
||||||
|
b.c.Telegram = NewTelegramService(b.log, b.repos)
|
||||||
|
b.c.Device.SetNotifier(b.c.Telegram.SendToUser)
|
||||||
|
b.c.Device.SetAdminNotifier(b.c.Telegram.SendToAdmin)
|
||||||
|
// 账号到期巡检:只负责发现与去重,发送复用同一个 Telegram 通道。
|
||||||
|
b.c.TelegramExpiry = NewTelegramExpiryWatcher(b.log, b.repos)
|
||||||
|
b.c.TelegramExpiry.SetUserNotifier(b.c.Telegram.SendToUser)
|
||||||
|
// SetExpiryWatcher must run AFTER TelegramExpiry is constructed.
|
||||||
|
b.c.Scheduler.SetExpiryWatcher(b.c.TelegramExpiry)
|
||||||
b.c.ApiConfig = NewApiConfigService(b.cfg, b.log, b.repos, b.c.Crypto)
|
b.c.ApiConfig = NewApiConfigService(b.cfg, b.log, b.repos, b.c.Crypto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"html"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -59,6 +62,9 @@ type TaskTrackerService struct {
|
|||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
hub *Hub
|
hub *Hub
|
||||||
|
|
||||||
|
// failureNotifier 在任务以失败收尾时通知管理员(Telegram)。nil 时静默。
|
||||||
|
failureNotifier func(ctx context.Context, text string)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
active map[string]*BackgroundTask
|
active map[string]*BackgroundTask
|
||||||
recent []BackgroundTask
|
recent []BackgroundTask
|
||||||
@@ -179,8 +185,48 @@ func (t *TaskTrackerService) finish(id string, err error, update TaskUpdate) {
|
|||||||
if len(t.recent) > t.maxRecent {
|
if len(t.recent) > t.maxRecent {
|
||||||
t.recent = t.recent[:t.maxRecent]
|
t.recent = t.recent[:t.maxRecent]
|
||||||
}
|
}
|
||||||
|
notifier := t.failureNotifier
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
t.publish(snapshot)
|
t.publish(snapshot)
|
||||||
|
|
||||||
|
// 失败通知放在锁外发送:网络请求绝不能阻塞任务状态机。
|
||||||
|
if err != nil && notifier != nil {
|
||||||
|
notifier(context.Background(), formatTaskFailureAlert(snapshot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFailureNotifier 注入任务失败时的管理员通知回调。
|
||||||
|
func (t *TaskTrackerService) SetFailureNotifier(fn func(ctx context.Context, text string)) {
|
||||||
|
if t == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
t.failureNotifier = fn
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatTaskFailureAlert 生成面向管理员的失败摘要。错误信息可能很长
|
||||||
|
// (ffmpeg 输出等),这里截断,避免超出 Telegram 单条消息长度。
|
||||||
|
func formatTaskFailureAlert(task BackgroundTask) string {
|
||||||
|
const maxErrRunes = 500
|
||||||
|
text := strings.TrimSpace(task.Error)
|
||||||
|
runes := []rune(text)
|
||||||
|
if len(runes) > maxErrRunes {
|
||||||
|
text = string(runes[:maxErrRunes]) + "…"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("⚠️ 任务失败:<b>")
|
||||||
|
b.WriteString(html.EscapeString(task.Name))
|
||||||
|
b.WriteString("</b>")
|
||||||
|
if task.SourcePath != "" {
|
||||||
|
b.WriteString("\n来源:")
|
||||||
|
b.WriteString(html.EscapeString(task.SourcePath))
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
b.WriteString("\n错误:")
|
||||||
|
b.WriteString(html.EscapeString(text))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TaskTrackerService) currentTime() time.Time {
|
func (t *TaskTrackerService) currentTime() time.Time {
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Telegram 通知相关的设置键。全部存在 settings 表,由管理员在「设备与通知」
|
||||||
|
// 设置分组里维护;带安全默认值:未启用时所有发送静默跳过。
|
||||||
|
const (
|
||||||
|
SettingTelegramEnabled = "telegram.enabled" // 总开关(默认关)
|
||||||
|
SettingTelegramBotToken = "telegram.bot_token" // Bot API Token
|
||||||
|
SettingTelegramAdminChatID = "telegram.admin_chat_id" // 管理员接收运维通知的会话 ID
|
||||||
|
)
|
||||||
|
|
||||||
|
// telegramBindAlphabet 去掉容易看错的 0/O/1/I,降低手抄绑定码时的出错率。
|
||||||
|
const telegramBindAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// telegramBindTTL 是绑定码的有效期。到期后必须重新生成。
|
||||||
|
telegramBindTTL = 5 * time.Minute
|
||||||
|
// telegramAPITimeout 覆盖单次 sendMessage 请求。
|
||||||
|
telegramAPITimeout = 10 * time.Second
|
||||||
|
// telegramPollClientTimeout 是长轮询专用 HTTP 客户端的超时,必须 > telegramPollTimeout。
|
||||||
|
telegramPollClientTimeout = 35 * time.Second
|
||||||
|
// telegramPollTimeout 是 getUpdates 的长轮询等待秒数。
|
||||||
|
telegramPollTimeout = 30
|
||||||
|
// telegramPollBackoff 是长轮询失败后的重试间隔,避免打爆 API。
|
||||||
|
telegramPollBackoff = 5 * time.Second
|
||||||
|
// telegramDisabledCheckInterval 是禁用状态下检查配置变化的间隔。
|
||||||
|
telegramDisabledCheckInterval = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrTelegramBindInvalid 表示绑定码不存在(不存在、已被使用,或已被新码取代)。
|
||||||
|
ErrTelegramBindInvalid = errors.New("telegram bind code not found")
|
||||||
|
// ErrTelegramBindExpired 表示绑定码已过期。
|
||||||
|
ErrTelegramBindExpired = errors.New("telegram bind code expired")
|
||||||
|
)
|
||||||
|
|
||||||
|
// telegramBindPending 记录一个待消费的绑定码。
|
||||||
|
type telegramBindPending struct {
|
||||||
|
UserID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelegramService 负责把通知发到 Telegram,并提供账号绑定。
|
||||||
|
//
|
||||||
|
// 范围被刻意收窄:Bot 只处理 /bind 与 /start 两条命令,不做开注、签到、
|
||||||
|
// 兑换码等命令体系。绑定走「网页生成一次性码 → Bot 端 /bind <code>」,
|
||||||
|
// 这样服务端不需要用户手工填写 chat id。
|
||||||
|
type TelegramService struct {
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
pending map[string]telegramBindPending // code -> pending
|
||||||
|
byUser map[string]string // userID -> code(同一用户只保留最新码)
|
||||||
|
|
||||||
|
// apiBase 允许测试指向本地假服务;生产固定为 Telegram 官方地址。
|
||||||
|
apiBase string
|
||||||
|
// lastUpdateID 是 getUpdates 的增量水位,避免重复处理同一条命令。
|
||||||
|
lastUpdateID int64
|
||||||
|
// client 可注入,便于测试替换传输层(sendMessage 等短请求)。
|
||||||
|
client *http.Client
|
||||||
|
// pollClient 专用于 getUpdates 长轮询,超时须 > telegramPollTimeout。
|
||||||
|
pollClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTelegramService 构建 TelegramService。
|
||||||
|
func NewTelegramService(log *zap.Logger, repo *repository.Container) *TelegramService {
|
||||||
|
return &TelegramService{
|
||||||
|
log: log,
|
||||||
|
repo: repo,
|
||||||
|
pending: make(map[string]telegramBindPending),
|
||||||
|
byUser: make(map[string]string),
|
||||||
|
apiBase: "https://api.telegram.org",
|
||||||
|
client: &http.Client{Timeout: telegramAPITimeout},
|
||||||
|
pollClient: &http.Client{Timeout: telegramPollClientTimeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start 在后台运行 Bot 命令长轮询。goroutine 始终启动;
|
||||||
|
// 未启用或未配置 Token 时 pollLoop 内部休眠等待配置就绪,ctx 结束时停止。
|
||||||
|
func (s *TelegramService) Start(ctx context.Context) {
|
||||||
|
if s == nil || s.repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helper.Go(s.log, "service.telegramBotPoll", func() { s.pollLoop(ctx) })
|
||||||
|
if s.log != nil {
|
||||||
|
s.log.Info("telegram poller started")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type telegramConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
BotToken string
|
||||||
|
AdminID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TelegramService) config(ctx context.Context) telegramConfig {
|
||||||
|
return telegramConfig{
|
||||||
|
Enabled: s.enabled(ctx),
|
||||||
|
BotToken: s.setting(ctx, SettingTelegramBotToken),
|
||||||
|
AdminID: s.setting(ctx, SettingTelegramAdminChatID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TelegramService) setting(ctx context.Context, key string) string {
|
||||||
|
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, err := s.repo.Setting.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TelegramService) enabled(ctx context.Context) bool {
|
||||||
|
return parseBoolSetting(s.setting(ctx, SettingTelegramEnabled), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendToUser 把消息发给用户绑定的 Telegram 会话。未绑定、未启用或发送失败
|
||||||
|
// 都只记日志并返回:通知永远不能影响触发它的业务流程。
|
||||||
|
func (s *TelegramService) SendToUser(ctx context.Context, userID, htmlText string) {
|
||||||
|
if s == nil || s.repo == nil || userID == "" || strings.TrimSpace(htmlText) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := s.repo.User.FindByID(ctx, userID)
|
||||||
|
if err != nil || u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chatID := strings.TrimSpace(u.TelegramChatID)
|
||||||
|
if chatID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.sendMessage(ctx, chatID, htmlText); err != nil {
|
||||||
|
s.warn("telegram send to user failed", userID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendToAdmin 把消息发给管理员会话,用于运维类事件(任务失败等)。
|
||||||
|
func (s *TelegramService) SendToAdmin(ctx context.Context, htmlText string) {
|
||||||
|
if s == nil || s.repo == nil || strings.TrimSpace(htmlText) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chatID := s.setting(ctx, SettingTelegramAdminChatID)
|
||||||
|
if chatID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.sendMessage(ctx, chatID, htmlText); err != nil {
|
||||||
|
s.warn("telegram send to admin failed", chatID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TelegramService) warn(msg, target string, err error) {
|
||||||
|
if s.log == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Warn(msg, zap.String("target", target), zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured 报告通知是否已具备发送条件:启用 + Token + 管理员会话都齐了。
|
||||||
|
func (s *TelegramService) Configured(ctx context.Context) bool {
|
||||||
|
if s == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cfg := s.config(ctx)
|
||||||
|
return cfg.Enabled && cfg.BotToken != "" && cfg.AdminID != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendToAdminChecked 是需要把失败反馈给管理员的场景(例如设置页的测试按钮)。
|
||||||
|
// 它配置未就绪时返回错误;普通事件通知仍应使用静默的 SendToAdmin。
|
||||||
|
func (s *TelegramService) SendToAdminChecked(ctx context.Context, htmlText string) error {
|
||||||
|
if s == nil || s.repo == nil {
|
||||||
|
return errors.New("telegram service unavailable")
|
||||||
|
}
|
||||||
|
chatID := s.setting(ctx, SettingTelegramAdminChatID)
|
||||||
|
if chatID == "" {
|
||||||
|
return errors.New("未配置管理员 Chat ID")
|
||||||
|
}
|
||||||
|
if !s.enabled(ctx) {
|
||||||
|
return errors.New("Telegram 通知未启用")
|
||||||
|
}
|
||||||
|
if s.setting(ctx, SettingTelegramBotToken) == "" {
|
||||||
|
return errors.New("未配置 Bot Token")
|
||||||
|
}
|
||||||
|
return s.sendMessage(ctx, chatID, htmlText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessage 是唯一的出网点。所有前置条件在这里统一校验。
|
||||||
|
func (s *TelegramService) sendMessage(ctx context.Context, chatID, htmlText string) error {
|
||||||
|
cfg := s.config(ctx)
|
||||||
|
if !cfg.Enabled || cfg.BotToken == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload := map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"text": htmlText,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
}
|
||||||
|
return s.call(ctx, cfg.BotToken, "sendMessage", payload, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// call 发起一次 Bot API 调用(使用短请求客户端)。
|
||||||
|
func (s *TelegramService) call(ctx context.Context, token, method string, payload map[string]any, out any) error {
|
||||||
|
return s.callWithClient(ctx, s.client, token, method, payload, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callWithClient 发起一次 Bot API 调用,允许注入自定义 HTTP 客户端(例如长轮询专用)。
|
||||||
|
// 失败时返回 Telegram 的 description,便于排查是 Token 错误、chat 不存在还是被限流。
|
||||||
|
func (s *TelegramService) callWithClient(ctx context.Context, client *http.Client, token, method string, payload map[string]any, out any) error {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("%s/bot%s/%s", s.apiBase, token, method)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: telegramAPITimeout}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||||
|
return fmt.Errorf("telegram %s: decode response: %w", method, err)
|
||||||
|
}
|
||||||
|
if !envelope.OK {
|
||||||
|
if envelope.Description == "" {
|
||||||
|
envelope.Description = fmt.Sprintf("http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("telegram %s: %s", method, envelope.Description)
|
||||||
|
}
|
||||||
|
if out != nil && len(envelope.Result) > 0 {
|
||||||
|
return json.Unmarshal(envelope.Result, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartBind 生成一次性绑定码。同一用户重复调用时旧码立即失效。
|
||||||
|
func (s *TelegramService) StartBind(ctx context.Context, userID string) (string, error) {
|
||||||
|
if s == nil || strings.TrimSpace(userID) == "" {
|
||||||
|
return "", errors.New("telegram bind: empty user id")
|
||||||
|
}
|
||||||
|
code, err := randomBindCode()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if prev, ok := s.byUser[userID]; ok {
|
||||||
|
delete(s.pending, prev)
|
||||||
|
}
|
||||||
|
s.pending[code] = telegramBindPending{UserID: userID, ExpiresAt: time.Now().Add(telegramBindTTL)}
|
||||||
|
s.byUser[userID] = code
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteBind 消费绑定码并写入用户的 TelegramChatID。
|
||||||
|
// 只在数据库写入成功后才删除 pending 中的绑定码;失败时码保留以便重试。
|
||||||
|
func (s *TelegramService) CompleteBind(ctx context.Context, code, chatID string) error {
|
||||||
|
if s == nil || s.repo == nil {
|
||||||
|
return ErrTelegramBindInvalid
|
||||||
|
}
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
chatID = strings.TrimSpace(chatID)
|
||||||
|
if code == "" || chatID == "" {
|
||||||
|
return ErrTelegramBindInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先读取,不删除——只有在操作成功后才消费绑定码。
|
||||||
|
s.mu.Lock()
|
||||||
|
pending, ok := s.pending[code]
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return ErrTelegramBindInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(pending.ExpiresAt) {
|
||||||
|
// 过期码:删除并返回,不再保留。
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.pending, code)
|
||||||
|
if s.byUser[pending.UserID] == code {
|
||||||
|
delete(s.byUser, pending.UserID)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return ErrTelegramBindExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.User.UpdateFields(ctx, pending.UserID, map[string]any{"telegram_chat_id": chatID}); err != nil {
|
||||||
|
// DB 失败时保留 pending,调用方可重试。
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入成功后再消费绑定码。
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.pending, code)
|
||||||
|
if s.byUser[pending.UserID] == code {
|
||||||
|
delete(s.byUser, pending.UserID)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbind 清空用户的 Telegram 绑定。
|
||||||
|
func (s *TelegramService) Unbind(ctx context.Context, userID string) error {
|
||||||
|
if s == nil || s.repo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if code, ok := s.byUser[userID]; ok {
|
||||||
|
delete(s.pending, code)
|
||||||
|
delete(s.byUser, userID)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return s.repo.User.UpdateFields(ctx, userID, map[string]any{"telegram_chat_id": ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status 返回绑定状态与脱敏后的会话 ID,供个人资料页展示。
|
||||||
|
func (s *TelegramService) Status(ctx context.Context, userID string) (bool, string) {
|
||||||
|
if s == nil || s.repo == nil || userID == "" {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
u, err := s.repo.User.FindByID(ctx, userID)
|
||||||
|
if err != nil || u == nil || strings.TrimSpace(u.TelegramChatID) == "" {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
return true, MaskSecret(u.TelegramChatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskSecret 保留首尾各两个字符,中间以 *** 取代。用于 Token 与会话 ID
|
||||||
|
// 这类「界面需要辨识、但不应完整下发」的凭据。
|
||||||
|
func MaskSecret(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
r := []rune(value)
|
||||||
|
if len(r) <= 4 {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
return string(r[:2]) + "***" + string(r[len(r)-2:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomBindCode() (string, error) {
|
||||||
|
buf := make([]byte, 6)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out := make([]byte, 6)
|
||||||
|
for i, b := range buf {
|
||||||
|
out[i] = telegramBindAlphabet[int(b)%len(telegramBindAlphabet)]
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var telegramBindCommandPattern = regexp.MustCompile(`(?i)^/bind(?:@\w+)?\s+(\S+)\s*$`)
|
||||||
|
|
||||||
|
// pollLoop 长轮询 Bot 更新,只处理绑定相关的两条命令。
|
||||||
|
// 当 Telegram 未启用或 Token 未配置时,休眠后继续循环以便配置变更后自动激活。
|
||||||
|
func (s *TelegramService) pollLoop(ctx context.Context) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := s.config(ctx)
|
||||||
|
if !cfg.Enabled || cfg.BotToken == "" {
|
||||||
|
if !sleepCtx(ctx, telegramDisabledCheckInterval) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updates, err := s.fetchUpdates(ctx, cfg.BotToken)
|
||||||
|
if err != nil {
|
||||||
|
if s.log != nil && ctx.Err() == nil {
|
||||||
|
s.log.Warn("telegram getUpdates failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
if !sleepCtx(ctx, telegramPollBackoff) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, u := range updates {
|
||||||
|
if u.UpdateID >= s.lastUpdateID {
|
||||||
|
s.lastUpdateID = u.UpdateID + 1
|
||||||
|
}
|
||||||
|
s.handleUpdate(ctx, cfg.BotToken, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type telegramUpdate struct {
|
||||||
|
UpdateID int64 `json:"update_id"`
|
||||||
|
Message *struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Chat struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"chat"`
|
||||||
|
} `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TelegramService) fetchUpdates(ctx context.Context, token string) ([]telegramUpdate, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
offset := s.lastUpdateID
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"timeout": telegramPollTimeout,
|
||||||
|
// 只取消息更新,避免把频道/回调查询也塞进来。
|
||||||
|
"allowed_updates": []string{"message"},
|
||||||
|
}
|
||||||
|
if offset > 0 {
|
||||||
|
payload["offset"] = offset
|
||||||
|
}
|
||||||
|
var updates []telegramUpdate
|
||||||
|
if err := s.callWithClient(ctx, s.pollClient, token, "getUpdates", payload, &updates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return updates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TelegramService) handleUpdate(ctx context.Context, token string, u telegramUpdate) {
|
||||||
|
if u.Message == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(u.Message.Text)
|
||||||
|
chatID := fmt.Sprintf("%d", u.Message.Chat.ID)
|
||||||
|
if chatID == "0" || text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if match := telegramBindCommandPattern.FindStringSubmatch(text); match != nil {
|
||||||
|
s.replyBind(ctx, token, chatID, match[1])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.ToLower(text), "/start") {
|
||||||
|
if err := s.call(ctx, token, "sendMessage", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"text": "MeBox 通知绑定:在网页「个人资料 → Telegram 通知」生成 6 位绑定码,然后发送 <code>/bind 绑定码</code>。",
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
}, nil); err != nil {
|
||||||
|
s.warn("telegram /start reply failed", chatID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TelegramService) replyBind(ctx context.Context, token, chatID, code string) {
|
||||||
|
message := ""
|
||||||
|
switch err := s.CompleteBind(ctx, code, chatID); {
|
||||||
|
case err == nil:
|
||||||
|
message = "✅ 绑定成功,之后 MeBox 的账号与设备通知会发到这里。"
|
||||||
|
case errors.Is(err, ErrTelegramBindExpired):
|
||||||
|
message = "⌛️ 绑定码已过期,请在网页重新生成。"
|
||||||
|
case errors.Is(err, ErrTelegramBindInvalid):
|
||||||
|
message = "❌ 绑定码无效,请在网页重新生成后再试。"
|
||||||
|
default:
|
||||||
|
message = "⚠️ 绑定失败,请稍后重试或查看服务日志。"
|
||||||
|
}
|
||||||
|
if err := s.call(ctx, token, "sendMessage", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"text": message,
|
||||||
|
}, nil); err != nil {
|
||||||
|
s.warn("telegram bind reply failed", chatID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleepCtx 等待指定时长,ctx 结束则提前返回 false。
|
||||||
|
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||||
|
timer := time.NewTimer(d)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
case <-timer.C:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 任务失败必须通知管理员:否则只会停留在任务队列里等人自己发现。
|
||||||
|
func TestTaskFailureNotifiesAdmin(t *testing.T) {
|
||||||
|
repos := repository.New(newServiceTestDB(t))
|
||||||
|
tracker := NewTaskTrackerService(zap.NewNop(), nil)
|
||||||
|
|
||||||
|
type call struct{ text string }
|
||||||
|
var adminCalls []call
|
||||||
|
tracker.SetFailureNotifier(func(_ context.Context, text string) {
|
||||||
|
adminCalls = append(adminCalls, call{text})
|
||||||
|
})
|
||||||
|
|
||||||
|
h := tracker.Start(TaskKindOrganize, "自动整理", TaskUpdate{})
|
||||||
|
h.Finish(errors.New("disk full"), TaskUpdate{})
|
||||||
|
|
||||||
|
if len(adminCalls) != 1 {
|
||||||
|
t.Fatalf("admin notifications = %d, want 1", len(adminCalls))
|
||||||
|
}
|
||||||
|
if !strings.Contains(adminCalls[0].text, "自动整理") {
|
||||||
|
t.Fatalf("notification = %q, want it to name the task", adminCalls[0].text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(adminCalls[0].text, "disk full") {
|
||||||
|
t.Fatalf("notification = %q, want it to carry the error", adminCalls[0].text)
|
||||||
|
}
|
||||||
|
_ = repos
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态字段须 HTML 转义,避免路径/错误里的 <>& 破坏 parse_mode。
|
||||||
|
func TestTaskFailureAlertEscapesHTML(t *testing.T) {
|
||||||
|
got := formatTaskFailureAlert(BackgroundTask{
|
||||||
|
Name: "整理 <script>",
|
||||||
|
SourcePath: "C:\\a&b>c",
|
||||||
|
Error: "fail <b>now</b>",
|
||||||
|
})
|
||||||
|
for _, bad := range []string{"<script>", "a&b>c", "<b>now</b>"} {
|
||||||
|
if strings.Contains(got, bad) {
|
||||||
|
t.Fatalf("alert still contains raw %q: %s", bad, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"整理 <script>", "a&b>c", "fail <b>now</b>"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("alert missing escaped %q: %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功结束的任务不应触发失败通知。
|
||||||
|
func TestTaskSuccessDoesNotNotifyAdmin(t *testing.T) {
|
||||||
|
tracker := NewTaskTrackerService(zap.NewNop(), nil)
|
||||||
|
var count int
|
||||||
|
tracker.SetFailureNotifier(func(context.Context, string) { count++ })
|
||||||
|
|
||||||
|
h := tracker.Start(TaskKindOrganize, "自动整理", TaskUpdate{})
|
||||||
|
h.Finish(nil, TaskUpdate{})
|
||||||
|
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("admin notifications = %d, want 0", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未接线通知时,任务路径必须照常完成。
|
||||||
|
func TestTaskTrackerWorksWithoutNotifier(t *testing.T) {
|
||||||
|
tracker := NewTaskTrackerService(zap.NewNop(), nil)
|
||||||
|
h := tracker.Start(TaskKindScan, "扫描", TaskUpdate{})
|
||||||
|
h.Finish(errors.New("boom"), TaskUpdate{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiryWarningsTargetDueUsersOnly(t *testing.T) {
|
||||||
|
repos := repository.New(newServiceTestDB(t))
|
||||||
|
now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 48h 内到期(落在 "3d" 桶:1-3 天)→ 应该提醒
|
||||||
|
soon := now.Add(48 * time.Hour)
|
||||||
|
// 三十天后到期 → 不应提醒
|
||||||
|
far := now.Add(30 * 24 * time.Hour)
|
||||||
|
for _, u := range []*model.User{
|
||||||
|
{Base: model.Base{ID: "u-soon"}, Username: "soon", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &soon, TelegramChatID: "111"},
|
||||||
|
{Base: model.Base{ID: "u-far"}, Username: "far", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &far, TelegramChatID: "222"},
|
||||||
|
{Base: model.Base{ID: "u-never"}, Username: "never", PasswordHash: "x", Role: "user", IsActive: true, TelegramChatID: "333"},
|
||||||
|
} {
|
||||||
|
if err := repos.User.Create(ctx, u); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewTelegramExpiryWatcher(zap.NewNop(), repos)
|
||||||
|
svc.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
var notified []string
|
||||||
|
svc.SetUserNotifier(func(_ context.Context, userID, _ string) { notified = append(notified, userID) })
|
||||||
|
|
||||||
|
if err := svc.RunOnce(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(notified) != 1 || notified[0] != "u-soon" {
|
||||||
|
t.Fatalf("notified = %v, want [u-soon]", notified)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一桶重复运行不得重复打扰。
|
||||||
|
notified = nil
|
||||||
|
if err := svc.RunOnce(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(notified) != 0 {
|
||||||
|
t.Fatalf("second run notified %v, want none", notified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未绑定 Telegram 的到期用户不应被通知,且不应写入标记键。
|
||||||
|
func TestExpirySkipsUnboundUsers(t *testing.T) {
|
||||||
|
repos := repository.New(newServiceTestDB(t))
|
||||||
|
now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
soon := now.Add(48 * time.Hour)
|
||||||
|
// 未绑定(TelegramChatID 为空)
|
||||||
|
unbound := &model.User{Base: model.Base{ID: "u-unbound"}, Username: "unbound", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &soon}
|
||||||
|
// 已绑定
|
||||||
|
bound := &model.User{Base: model.Base{ID: "u-bound"}, Username: "bound", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &soon, TelegramChatID: "999"}
|
||||||
|
for _, u := range []*model.User{unbound, bound} {
|
||||||
|
if err := repos.User.Create(ctx, u); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewTelegramExpiryWatcher(zap.NewNop(), repos)
|
||||||
|
svc.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
var notified []string
|
||||||
|
svc.SetUserNotifier(func(_ context.Context, userID, _ string) { notified = append(notified, userID) })
|
||||||
|
|
||||||
|
if err := svc.RunOnce(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, id := range notified {
|
||||||
|
if id == "u-unbound" {
|
||||||
|
t.Fatal("unbound user must not be notified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, id := range notified {
|
||||||
|
if id == "u-bound" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("bound user must be notified")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未绑定用户不应写标记键:再次跑时仍然跳过(不重复通知)。
|
||||||
|
notified = nil
|
||||||
|
if err := svc.RunOnce(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, id := range notified {
|
||||||
|
if id == "u-unbound" {
|
||||||
|
t.Fatal("unbound user notified on second run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两个提醒桶(3d / 1d)各触发一次,互不干扰。
|
||||||
|
func TestExpiryTwoBuckets(t *testing.T) {
|
||||||
|
repos := repository.New(newServiceTestDB(t))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
now := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
|
||||||
|
// 用户的到期时间在 "1d" 桶内
|
||||||
|
oneDay := now.Add(12 * time.Hour)
|
||||||
|
u := &model.User{Base: model.Base{ID: "u1"}, Username: "alice", PasswordHash: "x", Role: "user", IsActive: true, ExpiredAt: &oneDay, TelegramChatID: "555"}
|
||||||
|
if err := repos.User.Create(ctx, u); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewTelegramExpiryWatcher(zap.NewNop(), repos)
|
||||||
|
svc.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
var count int
|
||||||
|
svc.SetUserNotifier(func(context.Context, string, string) { count++ })
|
||||||
|
|
||||||
|
// 第一次运行:1d 桶触发
|
||||||
|
if err := svc.RunOnce(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("first run: count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二次运行:1d 桶已标记,不再触发
|
||||||
|
if err := svc.RunOnce(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("second run: count = %d, want still 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// expiryNotifiedKeyPrefix 记录「某个用户在某个提醒桶已经提醒过」,防止重复打扰。
|
||||||
|
// 键格式:telegram.expiry_notified.{userID}.{bucket},bucket 为 "3d" 或 "1d"。
|
||||||
|
const expiryNotifiedKeyPrefix = "telegram.expiry_notified."
|
||||||
|
|
||||||
|
// expiryWarnWindow 是「即将到期」的判定总窗口(所有提醒桶中最大的上限)。
|
||||||
|
const expiryWarnWindow = 72 * time.Hour
|
||||||
|
|
||||||
|
// expiryBucket 描述一个提醒时间窗口。
|
||||||
|
type expiryBucket struct {
|
||||||
|
name string
|
||||||
|
from time.Duration // 剩余时间下限(不含)
|
||||||
|
to time.Duration // 剩余时间上限(含)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expiryBuckets 定义两次提醒:约 3 天前 / 约 1 天前,各提醒一次。
|
||||||
|
var expiryBuckets = []expiryBucket{
|
||||||
|
{"3d", 24 * time.Hour, 72 * time.Hour}, // 1–3 天
|
||||||
|
{"1d", 0, 24 * time.Hour}, // 0–1 天
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelegramExpiryWatcher 每日巡检即将到期的账号并提醒用户。
|
||||||
|
//
|
||||||
|
// 只负责「发现 + 通知 + 去重」,发送本身交给注入的 notifier,因此没有配置
|
||||||
|
// Telegram 时整条链路静默跳过。
|
||||||
|
type TelegramExpiryWatcher struct {
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
userNotifier func(ctx context.Context, userID, text string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTelegramExpiryWatcher 构建巡检器。
|
||||||
|
func NewTelegramExpiryWatcher(log *zap.Logger, repo *repository.Container) *TelegramExpiryWatcher {
|
||||||
|
return &TelegramExpiryWatcher{log: log, repo: repo, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserNotifier 注入用户通知回调(通常是 TelegramService.SendToUser)。
|
||||||
|
func (w *TelegramExpiryWatcher) SetUserNotifier(fn func(ctx context.Context, userID, text string)) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.userNotifier = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunOnce 执行一次巡检。每个用户每个提醒桶("3d" / "1d")最多触发一次,
|
||||||
|
// 未绑定 Telegram 的用户跳过且不写标记键。
|
||||||
|
func (w *TelegramExpiryWatcher) RunOnce(ctx context.Context) error {
|
||||||
|
if w == nil || w.repo == nil || w.repo.User == nil || w.userNotifier == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := w.now()
|
||||||
|
|
||||||
|
for _, bucket := range expiryBuckets {
|
||||||
|
from := now.Add(bucket.from) // expired_at > from
|
||||||
|
to := now.Add(bucket.to) // expired_at <= to
|
||||||
|
|
||||||
|
users, err := w.dueUsers(ctx, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
if u.ExpiredAt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 跳过未绑定 Telegram 的用户,且不消耗标记键。
|
||||||
|
if strings.TrimSpace(u.TelegramChatID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
markKey := expiryNotifiedKeyPrefix + u.ID + "." + bucket.name
|
||||||
|
if seen, err := w.repo.Setting.Get(ctx, markKey); err == nil && strings.TrimSpace(seen) != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w.userNotifier(ctx, u.ID, fmt.Sprintf(
|
||||||
|
"⏳ 账号 <b>%s</b> 将于 %s 到期,请及时续期,避免到期后无法登录。",
|
||||||
|
html.EscapeString(u.Username), u.ExpiredAt.In(time.Local).Format("2006-01-02 15:04"),
|
||||||
|
))
|
||||||
|
// 仅在发送(尝试)后才写标记键。
|
||||||
|
if err := w.repo.Setting.Set(ctx, markKey, "1"); err != nil && w.log != nil {
|
||||||
|
w.log.Warn("telegram expiry mark failed", zap.String("user_id", u.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dueUsers 返回 (now, deadline] 内到期且仍处于启用状态的账号。
|
||||||
|
func (w *TelegramExpiryWatcher) dueUsers(ctx context.Context, now, deadline time.Time) ([]*model.User, error) {
|
||||||
|
var users []model.User
|
||||||
|
err := w.repo.DB.WithContext(ctx).
|
||||||
|
Where("expired_at IS NOT NULL AND expired_at > ? AND expired_at <= ?", now, deadline).
|
||||||
|
Where("is_active = ?", true).
|
||||||
|
Find(&users).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*model.User, 0, len(users))
|
||||||
|
for i := range users {
|
||||||
|
out = append(out, &users[i])
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTelegramTestService(t *testing.T) (*TelegramService, *repository.Container) {
|
||||||
|
t.Helper()
|
||||||
|
repos := repository.New(newServiceTestDB(t))
|
||||||
|
return NewTelegramService(zap.NewNop(), repos), repos
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedTelegramTestUser(t *testing.T, repos *repository.Container, id, chatID string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := repos.User.Create(context.Background(), &model.User{
|
||||||
|
Base: model.Base{ID: id},
|
||||||
|
Username: "tg-" + id,
|
||||||
|
PasswordHash: "x",
|
||||||
|
Role: "user",
|
||||||
|
IsActive: true,
|
||||||
|
TelegramChatID: chatID,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartBindReturnsSixCharCode(t *testing.T) {
|
||||||
|
s, _ := newTelegramTestService(t)
|
||||||
|
code, err := s.StartBind(context.Background(), "u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(code) != 6 {
|
||||||
|
t.Fatalf("code = %q, want 6 chars", code)
|
||||||
|
}
|
||||||
|
for _, r := range code {
|
||||||
|
if !strings.ContainsRune(telegramBindAlphabet, r) {
|
||||||
|
t.Fatalf("code %q contains unexpected rune %q", code, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一用户重复申请绑定码时,旧码必须立即失效,避免多个有效码并存。
|
||||||
|
func TestStartBindReplacesPreviousCode(t *testing.T) {
|
||||||
|
s, _ := newTelegramTestService(t)
|
||||||
|
first, err := s.StartBind(context.Background(), "u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := s.StartBind(context.Background(), "u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first == second {
|
||||||
|
t.Skip("random collision, rerun")
|
||||||
|
}
|
||||||
|
if err := s.CompleteBind(context.Background(), first, "111"); err == nil {
|
||||||
|
t.Fatal("expected the superseded code to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteBindRejectsExpiredCode(t *testing.T) {
|
||||||
|
s, _ := newTelegramTestService(t)
|
||||||
|
code, err := s.StartBind(context.Background(), "u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.pending[code] = telegramBindPending{UserID: "u1", ExpiresAt: time.Now().Add(-time.Second)}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err := s.CompleteBind(context.Background(), code, "999"); err == nil {
|
||||||
|
t.Fatal("expected expired code to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteBindWritesChatID(t *testing.T) {
|
||||||
|
s, repos := newTelegramTestService(t)
|
||||||
|
seedTelegramTestUser(t, repos, "u1", "")
|
||||||
|
code, err := s.StartBind(context.Background(), "u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.CompleteBind(context.Background(), code, "987654321"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u, err := repos.User.FindByID(context.Background(), "u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if u.TelegramChatID != "987654321" {
|
||||||
|
t.Fatalf("chat id = %q, want 987654321", u.TelegramChatID)
|
||||||
|
}
|
||||||
|
// 绑定成功后码必须被消费,不能重复使用。
|
||||||
|
if err := s.CompleteBind(context.Background(), code, "987654321"); err == nil {
|
||||||
|
t.Fatal("expected the consumed code to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusMasksChatID(t *testing.T) {
|
||||||
|
s, repos := newTelegramTestService(t)
|
||||||
|
seedTelegramTestUser(t, repos, "u1", "1234567890")
|
||||||
|
|
||||||
|
bound, masked := s.Status(context.Background(), "u1")
|
||||||
|
if !bound {
|
||||||
|
t.Fatal("expected bound user")
|
||||||
|
}
|
||||||
|
if strings.Contains(masked, "1234567890") {
|
||||||
|
t.Fatalf("chat id must not be exposed verbatim, got %q", masked)
|
||||||
|
}
|
||||||
|
if !strings.Contains(masked, "***") {
|
||||||
|
t.Fatalf("masked value should carry a *** marker, got %q", masked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnbindClearsChatID(t *testing.T) {
|
||||||
|
s, repos := newTelegramTestService(t)
|
||||||
|
seedTelegramTestUser(t, repos, "u1", "555")
|
||||||
|
|
||||||
|
if err := s.Unbind(context.Background(), "u1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u, err := repos.User.FindByID(context.Background(), "u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if u.TelegramChatID != "" {
|
||||||
|
t.Fatalf("chat id = %q, want empty", u.TelegramChatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未启用 / 未配置 token 时,发送必须静默返回,绝不能发起网络请求或以 panic 收场。
|
||||||
|
func TestSendNoopWhenDisabled(t *testing.T) {
|
||||||
|
s, repos := newTelegramTestService(t)
|
||||||
|
seedTelegramTestUser(t, repos, "u1", "1234567890")
|
||||||
|
|
||||||
|
s.SendToUser(context.Background(), "u1", "hello")
|
||||||
|
s.SendToAdmin(context.Background(), "hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未绑定 Telegram 的用户仍可能触发通知(例如首次登录),此时必须静默跳过。
|
||||||
|
func TestSendNoopWhenUserUnbound(t *testing.T) {
|
||||||
|
s, _ := newTelegramTestService(t)
|
||||||
|
s.SendToUser(context.Background(), "missing-user", "hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库写入失败时绑定码必须保留,让用户可以重试。
|
||||||
|
func TestCompleteBindKeepsPendingOnDBFailure(t *testing.T) {
|
||||||
|
db := newServiceTestDB(t)
|
||||||
|
repos := repository.New(db)
|
||||||
|
s := NewTelegramService(zap.NewNop(), repos)
|
||||||
|
|
||||||
|
code, err := s.StartBind(context.Background(), "u1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭底层数据库连接,强制 UpdateFields 失败。
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sqlDB.Close()
|
||||||
|
|
||||||
|
bindErr := s.CompleteBind(context.Background(), code, "999")
|
||||||
|
if bindErr == nil {
|
||||||
|
t.Fatal("expected an error from closed DB")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 码必须还在 pending 里,以便重试。
|
||||||
|
s.mu.Lock()
|
||||||
|
_, stillPresent := s.pending[code]
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !stillPresent {
|
||||||
|
t.Fatal("bind code must be retained when DB write fails, so the user can retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollLoop 在 Telegram 禁用状态下不应退出,而应持续等待配置开启。
|
||||||
|
func TestPollLoopContinuesWhenDisabled(t *testing.T) {
|
||||||
|
repos := repository.New(newServiceTestDB(t))
|
||||||
|
s := NewTelegramService(zap.NewNop(), repos)
|
||||||
|
|
||||||
|
// 使用一个立即取消的 context 验证 goroutine 能正常退出(不阻塞)。
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
s.pollLoop(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 给 goroutine 启动时间,然后取消 context。
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// ok: goroutine 正常退出
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("pollLoop did not exit after ctx cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/
|
|||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
|
// Serialize arrays as repeated params without brackets: genre=a&genre=b
|
||||||
|
// instead of axios's default genre[]=a&genre[]=b which Gin's QueryArray
|
||||||
|
// does not recognise.
|
||||||
|
paramsSerializer: { indexes: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
export const LONG_REQUEST_TIMEOUT = 120_000
|
export const LONG_REQUEST_TIMEOUT = 120_000
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
// 设备管理客户端。
|
||||||
|
//
|
||||||
|
// 普通用户走 /me/devices(服务端按会话身份过滤,前端无法越权指定他人);
|
||||||
|
// 管理员走 /admin/users/:id/devices 代管。
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
id: string
|
||||||
|
device_id: string
|
||||||
|
device_name?: string
|
||||||
|
client?: string
|
||||||
|
last_ip?: string
|
||||||
|
last_seen_at?: string
|
||||||
|
last_play_at?: string
|
||||||
|
kicked: boolean
|
||||||
|
online: boolean
|
||||||
|
playing: boolean
|
||||||
|
warnings: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceListResponse {
|
||||||
|
devices: DeviceInfo[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const devicesAPI = {
|
||||||
|
listMine: () =>
|
||||||
|
api.get<DeviceListResponse>('/me/devices').then((r) => r.data.devices ?? []),
|
||||||
|
|
||||||
|
kickMine: (deviceId: string) =>
|
||||||
|
api.post(`/me/devices/${encodeURIComponent(deviceId)}/kick`).then((r) => r.data),
|
||||||
|
|
||||||
|
kickAllMine: () => api.post('/me/devices/kick-all').then((r) => r.data),
|
||||||
|
|
||||||
|
listForUser: (userId: string) =>
|
||||||
|
api
|
||||||
|
.get<DeviceListResponse>(`/admin/users/${encodeURIComponent(userId)}/devices`)
|
||||||
|
.then((r) => r.data.devices ?? []),
|
||||||
|
|
||||||
|
kickForUser: (userId: string, deviceId: string) =>
|
||||||
|
api
|
||||||
|
.post(
|
||||||
|
`/admin/users/${encodeURIComponent(userId)}/devices/${encodeURIComponent(deviceId)}/kick`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
kickAllForUser: (userId: string) =>
|
||||||
|
api.post(`/admin/users/${encodeURIComponent(userId)}/devices/kick-all`).then((r) => r.data),
|
||||||
|
}
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
|
import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client'
|
||||||
import type { Library, LibraryRoot, Media, PlaybackInfo, ScanResult } from '../types'
|
import type { Library, LibraryRoot, Media, PlaybackInfo, ScanResult } from '../types'
|
||||||
import type { SeriesCard } from '../utils/groupSeries'
|
import type { SeriesCard } from '../utils/groupSeries'
|
||||||
|
import {
|
||||||
|
EMPTY_LIBRARY_FILTERS,
|
||||||
|
toFilterQuery,
|
||||||
|
type LibraryFilterParams,
|
||||||
|
} from '../utils/libraryFilters'
|
||||||
|
|
||||||
|
/** 媒体库筛选面板的可选项。 */
|
||||||
|
export interface LibraryFacets {
|
||||||
|
genres: Array<{ name: string; count: number }>
|
||||||
|
year_min: number
|
||||||
|
year_max: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface MediaPage {
|
export interface MediaPage {
|
||||||
items: Media[]
|
items: Media[]
|
||||||
@@ -165,7 +177,12 @@ export const libraryAPI = {
|
|||||||
id: string,
|
id: string,
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 50,
|
pageSize = 50,
|
||||||
options?: { groupVersions?: boolean; sort?: string; order?: 'asc' | 'desc' },
|
options?: {
|
||||||
|
groupVersions?: boolean
|
||||||
|
sort?: string
|
||||||
|
order?: 'asc' | 'desc'
|
||||||
|
filters?: LibraryFilterParams
|
||||||
|
},
|
||||||
) =>
|
) =>
|
||||||
api
|
api
|
||||||
.get<MediaPage>(`/libraries/${id}/media`, {
|
.get<MediaPage>(`/libraries/${id}/media`, {
|
||||||
@@ -175,20 +192,46 @@ export const libraryAPI = {
|
|||||||
group_versions: options?.groupVersions === false ? 0 : undefined,
|
group_versions: options?.groupVersions === false ? 0 : undefined,
|
||||||
sort: options?.sort,
|
sort: options?.sort,
|
||||||
order: options?.order,
|
order: options?.order,
|
||||||
|
...toFilterQuery(options?.filters ?? EMPTY_LIBRARY_FILTERS),
|
||||||
},
|
},
|
||||||
timeout: LONG_REQUEST_TIMEOUT,
|
timeout: LONG_REQUEST_TIMEOUT,
|
||||||
})
|
})
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** 媒体库筛选面板的可选项:类型清单与年份区间。 */
|
||||||
|
facets: (id: string) =>
|
||||||
|
api
|
||||||
|
.get<LibraryFacets>(`/libraries/${id}/facets`, { timeout: LONG_REQUEST_TIMEOUT })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
/** 「随便看看」:按同一套筛选条件随机取一条,无命中时抛 404。 */
|
||||||
|
random: (id: string, filters?: LibraryFilterParams) =>
|
||||||
|
api
|
||||||
|
.get<Media>(`/libraries/${id}/random`, {
|
||||||
|
params: toFilterQuery(filters ?? EMPTY_LIBRARY_FILTERS),
|
||||||
|
timeout: LONG_REQUEST_TIMEOUT,
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
listSeries: (
|
listSeries: (
|
||||||
id: string,
|
id: string,
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 500,
|
pageSize = 500,
|
||||||
options?: { sort?: string; order?: 'asc' | 'desc' },
|
options?: {
|
||||||
|
sort?: string
|
||||||
|
order?: 'asc' | 'desc'
|
||||||
|
filters?: LibraryFilterParams
|
||||||
|
},
|
||||||
) =>
|
) =>
|
||||||
api
|
api
|
||||||
.get<SeriesPage>(`/libraries/${id}/series`, {
|
.get<SeriesPage>(`/libraries/${id}/series`, {
|
||||||
params: { page, page_size: pageSize, sort: options?.sort, order: options?.order },
|
params: {
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
sort: options?.sort,
|
||||||
|
order: options?.order,
|
||||||
|
...toFilterQuery(options?.filters ?? EMPTY_LIBRARY_FILTERS),
|
||||||
|
},
|
||||||
timeout: LONG_REQUEST_TIMEOUT,
|
timeout: LONG_REQUEST_TIMEOUT,
|
||||||
})
|
})
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
// 统计相关客户端。
|
||||||
|
//
|
||||||
|
// 个人统计走 /watch-history/stats(服务端按会话身份取数);全站排行走
|
||||||
|
// /stats/top-users,服务端仅允许管理员访问。
|
||||||
|
|
||||||
|
export interface TopUserEntry {
|
||||||
|
user_id: string
|
||||||
|
username: string
|
||||||
|
plays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopUsersResponse {
|
||||||
|
items: TopUserEntry[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statsAPI = {
|
||||||
|
topUsers: (limit = 5) =>
|
||||||
|
api
|
||||||
|
.get<TopUsersResponse>(`/stats/top-users?limit=${limit}`)
|
||||||
|
.then((r) => r.data.items ?? []),
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
// Telegram 通知绑定客户端。
|
||||||
|
//
|
||||||
|
// 绑定流程:startBind() 拿一次性码 → 用户在 Bot 里发 /bind <码> → 轮询 status()
|
||||||
|
// 直到 bound 变 true。服务端不接收用户手填的 chat id。
|
||||||
|
|
||||||
|
export interface TelegramStatus {
|
||||||
|
bound: boolean
|
||||||
|
chat_id_masked?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TelegramBindCode {
|
||||||
|
code: string
|
||||||
|
expires_in_seconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const telegramAPI = {
|
||||||
|
status: () => api.get<TelegramStatus>('/me/telegram').then((r) => r.data),
|
||||||
|
|
||||||
|
startBind: () =>
|
||||||
|
api.post<TelegramBindCode>('/me/telegram/bind-code').then((r) => r.data),
|
||||||
|
|
||||||
|
unbind: () => api.delete('/me/telegram').then((r) => r.data),
|
||||||
|
|
||||||
|
test: () => api.post<{ success: boolean; error?: string }>('/admin/telegram/test').then((r) => r.data),
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ const MediaDetailPageLoader = () =>
|
|||||||
const PlayerPageLoader = () => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage }))
|
const PlayerPageLoader = () => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage }))
|
||||||
const WatchHistoryPageLoader = () =>
|
const WatchHistoryPageLoader = () =>
|
||||||
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage }))
|
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage }))
|
||||||
|
const WatchStatsPageLoader = () =>
|
||||||
|
import('./pages/WatchStatsPage').then((m) => ({ default: m.WatchStatsPage }))
|
||||||
|
|
||||||
const HomePage = lazy(HomePageLoader)
|
const HomePage = lazy(HomePageLoader)
|
||||||
const LibraryPage = lazy(LibraryPageLoader)
|
const LibraryPage = lazy(LibraryPageLoader)
|
||||||
@@ -30,6 +32,7 @@ const FileManagerPage = lazy(() =>
|
|||||||
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
|
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
|
||||||
)
|
)
|
||||||
const WatchHistoryPage = lazy(WatchHistoryPageLoader)
|
const WatchHistoryPage = lazy(WatchHistoryPageLoader)
|
||||||
|
const WatchStatsPage = lazy(WatchStatsPageLoader)
|
||||||
const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })))
|
const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })))
|
||||||
const ProfileManagementPage = lazy(() =>
|
const ProfileManagementPage = lazy(() =>
|
||||||
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
|
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
|
||||||
@@ -105,6 +108,7 @@ export const appRoutes: AppRoute[] = [
|
|||||||
{ path: 'profile', element: <ProfilePage /> },
|
{ path: 'profile', element: <ProfilePage /> },
|
||||||
{ path: 'dlna', element: <DlnaPage /> },
|
{ path: 'dlna', element: <DlnaPage /> },
|
||||||
{ path: 'history', element: <WatchHistoryPage /> },
|
{ path: 'history', element: <WatchHistoryPage /> },
|
||||||
|
{ path: 'history/stats', element: <WatchStatsPage /> },
|
||||||
{ path: 'poster-wall', element: <PosterWallPage /> },
|
{ path: 'poster-wall', element: <PosterWallPage /> },
|
||||||
{ path: 'play-profiles', element: <ProfileManagementPage /> },
|
{ path: 'play-profiles', element: <ProfileManagementPage /> },
|
||||||
{ path: 'api-configs', element: <Navigate to="/settings?group=api-configs" replace /> },
|
{ path: 'api-configs', element: <Navigate to="/settings?group=api-configs" replace /> },
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { Loader2, LogOut, MonitorSmartphone, RefreshCw, Smartphone, Tv } from 'lucide-react'
|
||||||
|
|
||||||
|
import { devicesAPI, type DeviceInfo } from '../api/devices'
|
||||||
|
|
||||||
|
// MyDevicesPanel 让用户自己看清「谁在用我的账号」,并能把可疑终端踢下线。
|
||||||
|
//
|
||||||
|
// 踢下线后该终端必须重新登录;这正是防共享策略生效时的自救路径。
|
||||||
|
export function MyDevicesPanel() {
|
||||||
|
const [devices, setDevices] = useState<DeviceInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
const [kickingAll, setKickingAll] = useState(false)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const rows = await devicesAPI.listMine()
|
||||||
|
if (mountedRef.current) setDevices(rows)
|
||||||
|
} catch {
|
||||||
|
if (mountedRef.current) toast.error('设备列表加载失败')
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const kick = async (device: DeviceInfo) => {
|
||||||
|
const id = device.device_id || device.id
|
||||||
|
setBusyId(id)
|
||||||
|
try {
|
||||||
|
await devicesAPI.kickMine(id)
|
||||||
|
toast.success('该设备已下线,需要重新登录')
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
toast.error('踢下线失败')
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kickAll = async () => {
|
||||||
|
setKickingAll(true)
|
||||||
|
try {
|
||||||
|
await devicesAPI.kickAllMine()
|
||||||
|
toast.success('全部设备已下线')
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
toast.error('操作失败')
|
||||||
|
} finally {
|
||||||
|
setKickingAll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="glass-panel space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 font-display text-lg font-semibold text-ink-600">
|
||||||
|
<MonitorSmartphone size={20} className="text-brand-500" />
|
||||||
|
我的设备
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-ink-50">
|
||||||
|
网页、手机与电视端登录过的终端都会出现在这里。发现陌生设备时请踢下线并修改密码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void load()}
|
||||||
|
className="rounded-xl border border-[var(--app-border)] p-2 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void kickAll()}
|
||||||
|
disabled={kickingAll || devices.length === 0}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl border border-rose-300 px-3 py-2 text-sm font-semibold text-rose-500 transition-colors hover:bg-rose-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{kickingAll ? <Loader2 size={16} className="animate-spin" /> : <LogOut size={16} />}
|
||||||
|
全部下线
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-8 text-ink-50">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-[var(--app-border)] p-6 text-center text-sm text-[var(--app-muted)]">
|
||||||
|
暂无设备记录。使用 Emby / Infuse 等客户端登录后会自动出现在这里。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{devices.map((device) => (
|
||||||
|
<li
|
||||||
|
key={device.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--app-text)]">
|
||||||
|
{deviceIcon(device)}
|
||||||
|
<span className="truncate">
|
||||||
|
{device.device_name || device.client || '未知设备'}
|
||||||
|
</span>
|
||||||
|
{device.playing && (
|
||||||
|
<span className="rounded-md bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-bold text-emerald-500">
|
||||||
|
播放中
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{device.kicked && (
|
||||||
|
<span className="rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-bold text-rose-500">
|
||||||
|
已下线
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap gap-x-3 text-xs text-[var(--app-muted)]">
|
||||||
|
{device.client && <span>{device.client}</span>}
|
||||||
|
{device.last_ip && <span className="font-mono">{device.last_ip}</span>}
|
||||||
|
{device.last_seen_at && (
|
||||||
|
<span>最近活跃 {formatTime(device.last_seen_at)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void kick(device)}
|
||||||
|
disabled={busyId === (device.device_id || device.id)}
|
||||||
|
className="shrink-0 rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-semibold text-[var(--app-muted)] transition-colors hover:border-rose-300 hover:text-rose-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busyId === (device.device_id || device.id) ? '下线中…' : '踢下线'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceIcon(device: DeviceInfo) {
|
||||||
|
const client = (device.client || '').toLowerCase()
|
||||||
|
if (client.includes('tv') || client.includes('infuse') || client.includes('emby')) {
|
||||||
|
return <Tv size={16} className="shrink-0 text-brand-500" />
|
||||||
|
}
|
||||||
|
return <Smartphone size={16} className="shrink-0 text-brand-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { Check, Copy, Loader2, Send, Unlink } from 'lucide-react'
|
||||||
|
|
||||||
|
import { telegramAPI, type TelegramStatus } from '../api/telegram'
|
||||||
|
|
||||||
|
// TelegramBindPanel 完成「网页生成一次性码 → Bot 内 /bind」的绑定流程。
|
||||||
|
//
|
||||||
|
// 绑定成功后,新设备登录、被踢下线、账号即将到期等事件都会推到用户的
|
||||||
|
// Telegram,避免这些动作变成静默行为。
|
||||||
|
const POLL_INTERVAL_MS = 3000
|
||||||
|
|
||||||
|
export function TelegramBindPanel() {
|
||||||
|
const [status, setStatus] = useState<TelegramStatus | null>(null)
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [expiresIn, setExpiresIn] = useState(0)
|
||||||
|
const [starting, setStarting] = useState(false)
|
||||||
|
const [unbinding, setUnbinding] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const mountedRef = useRef(true)
|
||||||
|
|
||||||
|
const refreshStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const next = await telegramAPI.status()
|
||||||
|
if (mountedRef.current) setStatus(next)
|
||||||
|
} catch {
|
||||||
|
if (mountedRef.current) setStatus({ bound: false })
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
void refreshStatus()
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [refreshStatus])
|
||||||
|
|
||||||
|
// 倒计时归零即作废本地展示的码,避免用户拿着过期码反复尝试。
|
||||||
|
useEffect(() => {
|
||||||
|
if (expiresIn <= 0) return
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setExpiresIn((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(timer)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return prev - 1
|
||||||
|
})
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [expiresIn])
|
||||||
|
|
||||||
|
// 等待用户在 Bot 里完成绑定:只在存在待消费的码时轮询。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!code || expiresIn <= 0 || status?.bound) return
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void refreshStatus()
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [code, expiresIn, status?.bound, refreshStatus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status?.bound && code) {
|
||||||
|
setCode('')
|
||||||
|
setExpiresIn(0)
|
||||||
|
toast.success('Telegram 绑定成功')
|
||||||
|
}
|
||||||
|
}, [status?.bound, code])
|
||||||
|
|
||||||
|
const startBind = async () => {
|
||||||
|
if (starting) return
|
||||||
|
setStarting(true)
|
||||||
|
setCopied(false)
|
||||||
|
try {
|
||||||
|
const res = await telegramAPI.startBind()
|
||||||
|
setCode(res.code)
|
||||||
|
setExpiresIn(res.expires_in_seconds)
|
||||||
|
} catch {
|
||||||
|
toast.error('生成绑定码失败,请检查服务端是否可用')
|
||||||
|
} finally {
|
||||||
|
setStarting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyCode = async () => {
|
||||||
|
if (!code) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(`/bind ${code}`)
|
||||||
|
setCopied(true)
|
||||||
|
toast.success('已复制 /bind 指令')
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
toast.error('复制失败,请手动输入')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unbind = async () => {
|
||||||
|
setUnbinding(true)
|
||||||
|
try {
|
||||||
|
await telegramAPI.unbind()
|
||||||
|
setCode('')
|
||||||
|
setExpiresIn(0)
|
||||||
|
await refreshStatus()
|
||||||
|
toast.success('已解除绑定')
|
||||||
|
} catch {
|
||||||
|
toast.error('解除绑定失败')
|
||||||
|
} finally {
|
||||||
|
setUnbinding(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bound = Boolean(status?.bound)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="glass-panel space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 font-display text-lg font-semibold text-ink-600">
|
||||||
|
<Send size={20} className="text-brand-500" />
|
||||||
|
Telegram 通知
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-ink-50">
|
||||||
|
绑定后,新设备登录、设备被踢下线、账号即将到期等事件会推送到你的 Telegram。
|
||||||
|
未绑定时这些事件只记录在服务端日志中。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bound ? (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-emerald-200 bg-emerald-50/40 p-4">
|
||||||
|
<div className="text-sm text-ink-600">
|
||||||
|
<span className="font-semibold">已绑定</span>
|
||||||
|
{status?.chat_id_masked && (
|
||||||
|
<span className="ml-2 font-mono text-xs text-ink-50">
|
||||||
|
{status.chat_id_masked}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void unbind()}
|
||||||
|
disabled={unbinding}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl border border-[var(--app-border)] px-3 py-2 text-sm font-semibold text-[var(--app-muted)] transition-colors hover:border-rose-300 hover:text-rose-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{unbinding ? <Loader2 size={16} className="animate-spin" /> : <Unlink size={16} />}
|
||||||
|
解除绑定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<ol className="list-decimal space-y-1 pl-5 text-sm text-ink-50">
|
||||||
|
<li>点击下方按钮生成 6 位绑定码(5 分钟内有效)。</li>
|
||||||
|
<li>在 Telegram 里打开 MeBox Bot,发送 <code className="font-mono">/bind 绑定码</code>。</li>
|
||||||
|
<li>收到「绑定成功」回复即完成,本页会自动刷新状态。</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{code && expiresIn > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-brand-200 bg-brand-50/40 p-4">
|
||||||
|
<span className="select-all font-mono text-2xl font-black tracking-widest text-brand-600">
|
||||||
|
{code}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyCode()}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-100 px-3 py-1.5 text-xs font-semibold text-brand-700 transition-colors hover:bg-brand-200"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{copied ? '已复制' : '复制 /bind 指令'}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-ink-50">
|
||||||
|
剩余 {Math.floor(expiresIn / 60)}:
|
||||||
|
{(expiresIn % 60).toString().padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void startBind()}
|
||||||
|
disabled={starting}
|
||||||
|
className="neon-button"
|
||||||
|
>
|
||||||
|
{starting ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||||
|
生成绑定码
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -127,6 +127,10 @@ export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
|
|||||||
if (pathname.startsWith('/library/')) {
|
if (pathname.startsWith('/library/')) {
|
||||||
return { to: '/libraries', label: '媒体库' }
|
return { to: '/libraries', label: '媒体库' }
|
||||||
}
|
}
|
||||||
|
// 统计页从观看历史进入,返回链也回到那里,而不是一路跳回首页。
|
||||||
|
if (pathname === '/history/stats') {
|
||||||
|
return { to: '/history', label: '观看历史' }
|
||||||
|
}
|
||||||
if (pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') {
|
if (pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') {
|
||||||
return { to: '/', label: '首页' }
|
return { to: '/', label: '首页' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Activity } from 'lucide-react'
|
||||||
|
|
||||||
|
import { statsAPI, type TopUserEntry } from '../api/stats'
|
||||||
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
// ActiveUsersStrip 在用户管理页顶部展示播放次数最多的几个账号。
|
||||||
|
//
|
||||||
|
// 只做展示与定位,不做筛选:管理员关心的是「谁在用」,点一下滚动到该用户
|
||||||
|
// 行即可继续原来的操作。非管理员拿到 403 时静默隐藏,避免出现红色报错条。
|
||||||
|
export function ActiveUsersStrip({ users }: { users: User[] }) {
|
||||||
|
const [top, setTop] = useState<TopUserEntry[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
statsAPI
|
||||||
|
.topUsers(5)
|
||||||
|
.then((rows) => {
|
||||||
|
if (!cancelled) setTop(rows)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setTop([])
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (top.length === 0) return null
|
||||||
|
|
||||||
|
const knownIDs = new Set(users.map((u) => u.id))
|
||||||
|
|
||||||
|
const focusUser = (userID: string) => {
|
||||||
|
const row = document.getElementById(`admin-user-${userID}`)
|
||||||
|
if (row) {
|
||||||
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="glass-panel">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs font-semibold text-ink-100">
|
||||||
|
<Activity size={14} className="text-brand-500" />
|
||||||
|
活跃用户 Top {top.length}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{top.map((entry) => {
|
||||||
|
// 已删除的账号仍会出现在历史统计里,此时不可点击。
|
||||||
|
const exists = knownIDs.has(entry.user_id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.user_id}
|
||||||
|
type="button"
|
||||||
|
disabled={!exists}
|
||||||
|
onClick={() => focusUser(entry.user_id)}
|
||||||
|
title={exists ? '定位到该用户' : '该账号已不存在'}
|
||||||
|
className="rounded-xl border border-sand-200 bg-white px-2.5 py-1 text-[11px] font-semibold text-ink-600 transition-colors enabled:hover:border-brand-300 enabled:hover:text-brand-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{entry.username || entry.user_id}
|
||||||
|
<span className="ml-1.5 font-mono text-[10px] text-sand-500">
|
||||||
|
{entry.plays} 次
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { Loader2, LogOut, MonitorSmartphone, RefreshCw, X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { devicesAPI, type DeviceInfo } from '../api/devices'
|
||||||
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
// AdminUserDevicesDialog 让管理员代管某个用户的登录设备。
|
||||||
|
//
|
||||||
|
// 使用场景:用户忘记密码/账号疑似外借/需要清掉旧电视端会话时,管理员不必
|
||||||
|
// 让用户自己操作,也不必进数据库。
|
||||||
|
type AdminUserDevicesDialogProps = {
|
||||||
|
user: User | null
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminUserDevicesDialog({ user, isOpen, onClose }: AdminUserDevicesDialogProps) {
|
||||||
|
const [devices, setDevices] = useState<DeviceInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
const [kickingAll, setKickingAll] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async (userId: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const rows = await devicesAPI.listForUser(userId)
|
||||||
|
setDevices(rows)
|
||||||
|
} catch {
|
||||||
|
toast.error('加载设备列表失败')
|
||||||
|
setDevices([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !user) return
|
||||||
|
setDevices([])
|
||||||
|
void load(user.id)
|
||||||
|
}, [isOpen, user, load])
|
||||||
|
|
||||||
|
if (!isOpen || !user) return null
|
||||||
|
|
||||||
|
const kick = async (device: DeviceInfo) => {
|
||||||
|
const id = device.device_id || device.id
|
||||||
|
setBusyId(id)
|
||||||
|
try {
|
||||||
|
await devicesAPI.kickForUser(user.id, id)
|
||||||
|
toast.success('设备已下线')
|
||||||
|
await load(user.id)
|
||||||
|
} catch {
|
||||||
|
toast.error('踢下线失败')
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kickAll = async () => {
|
||||||
|
setKickingAll(true)
|
||||||
|
try {
|
||||||
|
await devicesAPI.kickAllForUser(user.id)
|
||||||
|
toast.success(`已下线 ${user.username} 的全部设备`)
|
||||||
|
await load(user.id)
|
||||||
|
} catch {
|
||||||
|
toast.error('操作失败')
|
||||||
|
} finally {
|
||||||
|
setKickingAll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-2xl overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-sand-100 p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-50 text-brand-600">
|
||||||
|
<MonitorSmartphone size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-display text-lg font-bold text-ink-600">登录设备管理</h3>
|
||||||
|
<p className="text-xs text-sand-500">
|
||||||
|
用户:<span className="font-semibold text-ink-600">{user.username}</span>
|
||||||
|
<span className="ml-2 text-sand-400">
|
||||||
|
共 {devices.length} 台
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-xl p-2 text-sand-500 transition-colors hover:bg-sand-100 hover:text-ink-600"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto p-5">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-12 text-xs text-sand-500">
|
||||||
|
<Loader2 size={16} className="animate-spin text-brand-500" />
|
||||||
|
正在加载设备…
|
||||||
|
</div>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<div className="rounded-2xl bg-sand-50/50 py-10 text-center text-xs text-sand-500">
|
||||||
|
该用户暂无设备记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{devices.map((device) => (
|
||||||
|
<li
|
||||||
|
key={device.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-sand-200 p-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-ink-600">
|
||||||
|
<span className="truncate">
|
||||||
|
{device.device_name || device.client || '未知设备'}
|
||||||
|
</span>
|
||||||
|
{device.online && (
|
||||||
|
<span className="rounded-md bg-emerald-50 px-1.5 py-0.5 text-[10px] font-bold text-emerald-600">
|
||||||
|
在线
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{device.playing && (
|
||||||
|
<span className="rounded-md bg-brand-50 px-1.5 py-0.5 text-[10px] font-bold text-brand-600">
|
||||||
|
播放中
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{device.kicked && (
|
||||||
|
<span className="rounded-md bg-rose-50 px-1.5 py-0.5 text-[10px] font-bold text-rose-600">
|
||||||
|
已下线
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{device.warnings > 0 && (
|
||||||
|
<span className="rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-bold text-amber-600">
|
||||||
|
警告 {device.warnings}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap gap-x-3 text-[11px] text-sand-500">
|
||||||
|
{device.client && <span>{device.client}</span>}
|
||||||
|
{device.last_ip && <span className="font-mono">{device.last_ip}</span>}
|
||||||
|
{device.last_seen_at && <span>最近活跃 {formatTime(device.last_seen_at)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void kick(device)}
|
||||||
|
disabled={busyId === (device.device_id || device.id)}
|
||||||
|
className="shrink-0 rounded-xl border border-sand-200 px-3 py-1.5 text-[11px] font-semibold text-sand-500 transition-colors hover:border-rose-300 hover:text-rose-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{busyId === (device.device_id || device.id) ? '下线中…' : '踢下线'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2.5 border-t border-sand-100 bg-sand-50/60 px-5 py-3.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void load(user.id)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-xl border border-sand-200 bg-white px-3 py-2 text-xs font-semibold text-ink-600 transition-colors hover:bg-sand-100"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={kickingAll || devices.length === 0}
|
||||||
|
onClick={() => void kickAll()}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-xl border border-rose-300 bg-white px-3 py-2 text-xs font-semibold text-rose-500 transition-colors hover:bg-rose-50 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{kickingAll ? <Loader2 size={13} className="animate-spin" /> : <LogOut size={13} />}
|
||||||
|
全部下线
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string): string {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ import type { User } from '../types'
|
|||||||
import { confirmAction } from '../components/confirmAction'
|
import { confirmAction } from '../components/confirmAction'
|
||||||
import { requestPassword } from '../components/requestPassword'
|
import { requestPassword } from '../components/requestPassword'
|
||||||
import { AdminUserLibrariesDialog } from '../components/AdminUserLibrariesDialog'
|
import { AdminUserLibrariesDialog } from '../components/AdminUserLibrariesDialog'
|
||||||
|
import { AdminUserDevicesDialog } from './AdminUserDevicesDialog'
|
||||||
import { AdminUsersForm } from './AdminUsersForm'
|
import { AdminUsersForm } from './AdminUsersForm'
|
||||||
import { AdminUsersTable } from './AdminUsersTable'
|
import { AdminUsersTable } from './AdminUsersTable'
|
||||||
|
import { ActiveUsersStrip } from './ActiveUsersStrip'
|
||||||
|
|
||||||
const DEFAULT_MAX_USERS = 20
|
const DEFAULT_MAX_USERS = 20
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ export function AdminUsersPanel() {
|
|||||||
const [editingUsername, setEditingUsername] = useState('')
|
const [editingUsername, setEditingUsername] = useState('')
|
||||||
const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null)
|
const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null)
|
||||||
const [configuringLibrariesUser, setConfiguringLibrariesUser] = useState<User | null>(null)
|
const [configuringLibrariesUser, setConfiguringLibrariesUser] = useState<User | null>(null)
|
||||||
|
const [managingDevicesUser, setManagingDevicesUser] = useState<User | null>(null)
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const data = await adminAPI.listUsers()
|
const data = await adminAPI.listUsers()
|
||||||
@@ -187,6 +190,8 @@ export function AdminUsersPanel() {
|
|||||||
onSubmit={handleCreate}
|
onSubmit={handleCreate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ActiveUsersStrip users={users} />
|
||||||
|
|
||||||
<AdminUsersTable
|
<AdminUsersTable
|
||||||
users={users}
|
users={users}
|
||||||
editingID={editingID}
|
editingID={editingID}
|
||||||
@@ -198,6 +203,7 @@ export function AdminUsersPanel() {
|
|||||||
onStartEdit={startEdit}
|
onStartEdit={startEdit}
|
||||||
onResetPassword={resetPassword}
|
onResetPassword={resetPassword}
|
||||||
onConfigureLibraries={(u) => setConfiguringLibrariesUser(u)}
|
onConfigureLibraries={(u) => setConfiguringLibrariesUser(u)}
|
||||||
|
onManageDevices={(u) => setManagingDevicesUser(u)}
|
||||||
onToggleStatus={toggleStatus}
|
onToggleStatus={toggleStatus}
|
||||||
onDeleteUser={deleteUser}
|
onDeleteUser={deleteUser}
|
||||||
/>
|
/>
|
||||||
@@ -211,6 +217,12 @@ export function AdminUsersPanel() {
|
|||||||
await refresh()
|
await refresh()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AdminUserDevicesDialog
|
||||||
|
user={managingDevicesUser}
|
||||||
|
isOpen={Boolean(managingDevicesUser)}
|
||||||
|
onClose={() => setManagingDevicesUser(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
FolderLock,
|
FolderLock,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
MonitorSmartphone,
|
||||||
Pencil,
|
Pencil,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -23,6 +24,7 @@ type AdminUsersTableProps = {
|
|||||||
onStartEdit: (user: User) => void
|
onStartEdit: (user: User) => void
|
||||||
onResetPassword: (user: User) => void
|
onResetPassword: (user: User) => void
|
||||||
onConfigureLibraries: (user: User) => void
|
onConfigureLibraries: (user: User) => void
|
||||||
|
onManageDevices: (user: User) => void
|
||||||
onToggleStatus: (user: User) => void
|
onToggleStatus: (user: User) => void
|
||||||
onDeleteUser: (user: User) => void
|
onDeleteUser: (user: User) => void
|
||||||
}
|
}
|
||||||
@@ -38,6 +40,7 @@ export function AdminUsersTable({
|
|||||||
onStartEdit,
|
onStartEdit,
|
||||||
onResetPassword,
|
onResetPassword,
|
||||||
onConfigureLibraries,
|
onConfigureLibraries,
|
||||||
|
onManageDevices,
|
||||||
onToggleStatus,
|
onToggleStatus,
|
||||||
onDeleteUser,
|
onDeleteUser,
|
||||||
}: AdminUsersTableProps) {
|
}: AdminUsersTableProps) {
|
||||||
@@ -57,7 +60,7 @@ export function AdminUsersTable({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<tr key={u.id} className="border-t border-gray-200">
|
<tr key={u.id} id={`admin-user-${u.id}`} className="border-t border-gray-200">
|
||||||
<td className="py-2 text-ink-600">
|
<td className="py-2 text-ink-600">
|
||||||
{editingID === u.id ? (
|
{editingID === u.id ? (
|
||||||
<input
|
<input
|
||||||
@@ -147,6 +150,13 @@ export function AdminUsersTable({
|
|||||||
>
|
>
|
||||||
{resettingPasswordID === u.id ? <Loader2 size={12} className="animate-spin" /> : <KeyRound size={12} />}
|
{resettingPasswordID === u.id ? <Loader2 size={12} className="animate-spin" /> : <KeyRound size={12} />}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-sky-400/40 px-2 py-1 text-xs text-sky-500 hover:bg-sky-400/10"
|
||||||
|
title="登录设备管理"
|
||||||
|
onClick={() => onManageDevices(u)}
|
||||||
|
>
|
||||||
|
<MonitorSmartphone size={12} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={
|
className={
|
||||||
'rounded-lg border px-2 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ' +
|
'rounded-lg border px-2 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ' +
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ChevronDown, Dices, Filter, Loader2, X } from 'lucide-react'
|
||||||
|
|
||||||
|
import type { LibraryFacets } from '../api/library'
|
||||||
|
import {
|
||||||
|
hasActiveFilters,
|
||||||
|
normalizeYearRange,
|
||||||
|
type LibraryFilterParams,
|
||||||
|
} from '../utils/libraryFilters'
|
||||||
|
|
||||||
|
type LibraryFilterBarProps = {
|
||||||
|
facets: LibraryFacets | null
|
||||||
|
loadingFacets: boolean
|
||||||
|
filters: LibraryFilterParams
|
||||||
|
onApply: (filters: LibraryFilterParams) => void
|
||||||
|
onReset: () => void
|
||||||
|
/** 「随便看看」:接收面板当前有效草稿(已与 URL 保持同步后)作为筛选条件。 */
|
||||||
|
onRandom: (filters: LibraryFilterParams) => void
|
||||||
|
randomBusy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryFilterBar 提供库内筛选与「随便看看」。
|
||||||
|
//
|
||||||
|
// 交互约定:面板内的改动先落到本地 draft,点「应用」才写回 URL 并重新查询。
|
||||||
|
// 类型多选是高频操作,逐个触发请求会让大库反复重扫,因此不做即时生效。
|
||||||
|
export function LibraryFilterBar({
|
||||||
|
facets,
|
||||||
|
loadingFacets,
|
||||||
|
filters,
|
||||||
|
onApply,
|
||||||
|
onReset,
|
||||||
|
onRandom,
|
||||||
|
randomBusy,
|
||||||
|
}: LibraryFilterBarProps) {
|
||||||
|
const [open, setOpen] = useState(() => hasActiveFilters(filters))
|
||||||
|
const [draft, setDraft] = useState<LibraryFilterParams>(filters)
|
||||||
|
const [yearMin, setYearMin] = useState(filters.year_min ? String(filters.year_min) : '')
|
||||||
|
const [yearMax, setYearMax] = useState(filters.year_max ? String(filters.year_max) : '')
|
||||||
|
const [ratingMin, setRatingMin] = useState(filters.rating_min ? String(filters.rating_min) : '')
|
||||||
|
|
||||||
|
// URL 变化(例如浏览器返回)时把草稿同步回来,避免面板显示与结果不一致。
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(filters)
|
||||||
|
setYearMin(filters.year_min ? String(filters.year_min) : '')
|
||||||
|
setYearMax(filters.year_max ? String(filters.year_max) : '')
|
||||||
|
setRatingMin(filters.rating_min ? String(filters.rating_min) : '')
|
||||||
|
}, [filters])
|
||||||
|
|
||||||
|
const active = hasActiveFilters(filters)
|
||||||
|
|
||||||
|
const toggleGenre = (name: string) => {
|
||||||
|
setDraft((prev) => {
|
||||||
|
const exists = prev.genres.includes(name)
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
genres: exists ? prev.genres.filter((item) => item !== name) : [...prev.genres, name],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把当前草稿(含年份/评分输入框)合并成一个完整的 LibraryFilterParams。 */
|
||||||
|
const collectDraft = (): LibraryFilterParams => {
|
||||||
|
const range = normalizeYearRange(
|
||||||
|
yearMin ? Number(yearMin) : undefined,
|
||||||
|
yearMax ? Number(yearMax) : undefined,
|
||||||
|
)
|
||||||
|
const rating = ratingMin ? Number(ratingMin) : undefined
|
||||||
|
return {
|
||||||
|
genres: draft.genres,
|
||||||
|
...range,
|
||||||
|
rating_min: Number.isFinite(rating) && (rating ?? 0) > 0 ? rating : undefined,
|
||||||
|
unwatched: draft.unwatched,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
onApply(collectDraft())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 随便看看:若草稿与已应用的筛选不同,先把草稿写入 URL,再用草稿发起随机。
|
||||||
|
* 这样「应用筛选」与「随便看看」之间的不一致窗口缩短到零。 */
|
||||||
|
const handleRandom = () => {
|
||||||
|
const effective = collectDraft()
|
||||||
|
// 简单比对:序列化为 JSON 后对比,草稿改动均可检测到。
|
||||||
|
const draftChanged = JSON.stringify(effective) !== JSON.stringify(filters)
|
||||||
|
if (draftChanged) {
|
||||||
|
onApply(effective)
|
||||||
|
}
|
||||||
|
onRandom(effective)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setDraft({ genres: [] })
|
||||||
|
setYearMin('')
|
||||||
|
setYearMax('')
|
||||||
|
setRatingMin('')
|
||||||
|
onReset()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-semibold text-[var(--app-text)]"
|
||||||
|
>
|
||||||
|
<Filter size={15} className="text-brand-500" />
|
||||||
|
筛选
|
||||||
|
{active && (
|
||||||
|
<span className="rounded-md bg-brand-500/15 px-1.5 py-0.5 text-[10px] font-bold text-brand-500">
|
||||||
|
已启用
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={`text-[var(--app-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{active && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-muted)] transition-colors hover:text-[var(--app-text)]"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
清除筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRandom}
|
||||||
|
disabled={randomBusy}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-xl border border-[var(--app-brand-border)] bg-[var(--app-brand-soft)] px-3 py-1.5 text-xs font-bold text-[var(--app-brand-text)] transition-opacity disabled:opacity-50"
|
||||||
|
title="按当前筛选条件随机播放一条"
|
||||||
|
>
|
||||||
|
{randomBusy ? <Loader2 size={13} className="animate-spin" /> : <Dices size={13} />}
|
||||||
|
随便看看
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="space-y-4 border-t border-[var(--app-border)] px-3 py-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs font-semibold text-[var(--app-muted)]">类型</div>
|
||||||
|
{loadingFacets ? (
|
||||||
|
<div className="flex items-center gap-2 py-2 text-xs text-[var(--app-muted)]">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
正在加载类型…
|
||||||
|
</div>
|
||||||
|
) : !facets || facets.genres.length === 0 ? (
|
||||||
|
<div className="py-2 text-xs text-[var(--app-muted)]">
|
||||||
|
这个媒体库还没有刮削出类型信息
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{facets.genres.map((genre) => {
|
||||||
|
const selected = draft.genres.includes(genre.name)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={genre.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleGenre(genre.name)}
|
||||||
|
className={
|
||||||
|
'rounded-lg border px-2 py-1 text-xs transition-colors ' +
|
||||||
|
(selected
|
||||||
|
? 'border-brand-500 bg-brand-500 text-white'
|
||||||
|
: 'border-[var(--app-border)] text-[var(--app-subtle)] hover:bg-[var(--app-hover)]')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{genre.name}
|
||||||
|
<span className="ml-1 opacity-70">{genre.count}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="text-xs font-semibold text-[var(--app-muted)]">
|
||||||
|
年份
|
||||||
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-base w-20"
|
||||||
|
placeholder={facets?.year_min ? String(facets.year_min) : '不限'}
|
||||||
|
value={yearMin}
|
||||||
|
onChange={(e) => setYearMin(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-[var(--app-muted)]">–</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-base w-20"
|
||||||
|
placeholder={facets?.year_max ? String(facets.year_max) : '不限'}
|
||||||
|
value={yearMax}
|
||||||
|
onChange={(e) => setYearMax(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="text-xs font-semibold text-[var(--app-muted)]">
|
||||||
|
最低评分
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
className="input-base mt-1 w-20"
|
||||||
|
placeholder="不限"
|
||||||
|
value={ratingMin}
|
||||||
|
onChange={(e) => setRatingMin(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 pb-2 text-xs font-semibold text-[var(--app-muted)]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-brand-500"
|
||||||
|
checked={Boolean(draft.unwatched)}
|
||||||
|
onChange={(e) => setDraft((prev) => ({ ...prev, unwatched: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
只看未看完
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="button" onClick={apply} className="neon-button mb-0.5">
|
||||||
|
应用筛选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import { useCallback, useMemo, useState, Fragment, type ReactNode } from 'react'
|
import { useCallback, useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
|
||||||
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
|
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { libraryAPI, type LibraryFacets } from '../api/library'
|
||||||
|
import {
|
||||||
|
EMPTY_LIBRARY_FILTERS,
|
||||||
|
parseLibraryFilters,
|
||||||
|
withFilterParams,
|
||||||
|
type LibraryFilterParams,
|
||||||
|
} from '../utils/libraryFilters'
|
||||||
import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries'
|
import { isTheatricalFeature, type SeriesCard } from '../utils/groupSeries'
|
||||||
import {
|
import {
|
||||||
sortMediaList,
|
sortMediaList,
|
||||||
@@ -15,6 +23,7 @@ import { LibraryPageDialogs } from './LibraryPageDialogs'
|
|||||||
import { PageBackButton } from '../components/PageBackButton'
|
import { PageBackButton } from '../components/PageBackButton'
|
||||||
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
|
||||||
import { LibraryPageHeader } from './LibraryPageHeader'
|
import { LibraryPageHeader } from './LibraryPageHeader'
|
||||||
|
import { LibraryFilterBar } from './LibraryFilterBar'
|
||||||
import { LibraryMediaSections } from './LibraryMediaSections'
|
import { LibraryMediaSections } from './LibraryMediaSections'
|
||||||
import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
|
import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
|
||||||
import { useLibraryData } from './useLibraryData'
|
import { useLibraryData } from './useLibraryData'
|
||||||
@@ -28,6 +37,7 @@ export function LibraryPage() {
|
|||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
const role = useAuthStore((s) => s.user?.role)
|
const role = useAuthStore((s) => s.user?.role)
|
||||||
const canFavorite = usePermission('can_favorite')
|
const canFavorite = usePermission('can_favorite')
|
||||||
const { isFavourite, toggleFavourite } = useFavourites()
|
const { isFavourite, toggleFavourite } = useFavourites()
|
||||||
@@ -57,6 +67,60 @@ export function LibraryPage() {
|
|||||||
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
const [selectedSeries, setSelectedSeries] = useState<SeriesCard | null>(null)
|
||||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
|
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// 筛选状态放在 URL:可分享、刷新保持、浏览器返回可撤销。
|
||||||
|
const libraryFilters = useMemo(
|
||||||
|
() => parseLibraryFilters(location.search),
|
||||||
|
[location.search],
|
||||||
|
)
|
||||||
|
const [facets, setFacets] = useState<LibraryFacets | null>(null)
|
||||||
|
const [loadingFacets, setLoadingFacets] = useState(true)
|
||||||
|
const [randomBusy, setRandomBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoadingFacets(true)
|
||||||
|
libraryAPI
|
||||||
|
.facets(id)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setFacets(data)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setFacets(null)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoadingFacets(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
const applyFilters = (next: LibraryFilterParams) => {
|
||||||
|
navigate({ search: withFilterParams(location.search, next) }, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
navigate({ search: withFilterParams(location.search, EMPTY_LIBRARY_FILTERS) }, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「随便看看」直接进播放页:多一步详情页会削弱「随手看点什么」的意图。
|
||||||
|
// 接受来自 LibraryFilterBar 的有效草稿筛选(草稿与已应用不同时,面板已事先
|
||||||
|
// 调用 applyFilters 把草稿写入 URL,再用相同的条件发起随机请求以保证一致)。
|
||||||
|
const handleRandom = async (filters: LibraryFilterParams) => {
|
||||||
|
setRandomBusy(true)
|
||||||
|
try {
|
||||||
|
const media = await libraryAPI.random(id, filters)
|
||||||
|
navigate(`/play/${media.id}`)
|
||||||
|
} catch (err) {
|
||||||
|
const status = (err as { response?: { status?: number } })?.response?.status
|
||||||
|
toast.error(
|
||||||
|
status === 404 ? '没有符合当前筛选条件的媒体' : '随机播放失败,请稍后重试',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setRandomBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
library,
|
library,
|
||||||
items,
|
items,
|
||||||
@@ -73,7 +137,7 @@ export function LibraryPage() {
|
|||||||
seriesCards,
|
seriesCards,
|
||||||
loadingAllText,
|
loadingAllText,
|
||||||
reloadCurrentLibrary,
|
reloadCurrentLibrary,
|
||||||
} = useLibraryData(id, selectedSeries, serverSortField, serverSortOrder)
|
} = useLibraryData(id, selectedSeries, serverSortField, serverSortOrder, libraryFilters)
|
||||||
|
|
||||||
// 常规排序由服务端全局完成;只有 random 模式才在客户端洗牌。
|
// 常规排序由服务端全局完成;只有 random 模式才在客户端洗牌。
|
||||||
const displayedItems = useMemo(() => {
|
const displayedItems = useMemo(() => {
|
||||||
@@ -254,6 +318,16 @@ export function LibraryPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<LibraryFilterBar
|
||||||
|
facets={facets}
|
||||||
|
loadingFacets={loadingFacets}
|
||||||
|
filters={libraryFilters}
|
||||||
|
onApply={applyFilters}
|
||||||
|
onReset={resetFilters}
|
||||||
|
onRandom={(f) => void handleRandom(f)}
|
||||||
|
randomBusy={randomBusy}
|
||||||
|
/>
|
||||||
|
|
||||||
<LibraryMediaSections
|
<LibraryMediaSections
|
||||||
isSeries={isSeries}
|
isSeries={isSeries}
|
||||||
items={displayedItems}
|
items={displayedItems}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Check, Copy, EyeOff, KeyRound, Loader2, Save, Tv } from 'lucide-react'
|
|||||||
|
|
||||||
import { authAPI } from '../api/auth'
|
import { authAPI } from '../api/auth'
|
||||||
import { profileAPI } from '../api/profile'
|
import { profileAPI } from '../api/profile'
|
||||||
|
import { MyDevicesPanel } from '../components/MyDevicesPanel'
|
||||||
|
import { TelegramBindPanel } from '../components/TelegramBindPanel'
|
||||||
import { requestPassword } from '../components/requestPassword'
|
import { requestPassword } from '../components/requestPassword'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
@@ -288,6 +290,10 @@ export function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<MyDevicesPanel />
|
||||||
|
|
||||||
|
<TelegramBindPanel />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { AdultSettingsPanel } from './AdultSettingsPanel'
|
|||||||
import { DatabaseSettingsPanel } from './DatabaseSettingsPanel'
|
import { DatabaseSettingsPanel } from './DatabaseSettingsPanel'
|
||||||
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
|
import { RecognitionWordsPanel } from './RecognitionWordsPanel'
|
||||||
import { SettingRow } from './SettingsRow'
|
import { SettingRow } from './SettingsRow'
|
||||||
|
import { TelegramNotifyPanel } from './TelegramNotifyPanel'
|
||||||
import { ALL_KEYS, GROUPS } from './settingsGroups'
|
import { ALL_KEYS, GROUPS } from './settingsGroups'
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
@@ -176,6 +177,7 @@ export function SettingsPage() {
|
|||||||
{group.key === 'adult' && <AdultSettingsPanel />}
|
{group.key === 'adult' && <AdultSettingsPanel />}
|
||||||
{group.key === 'general' && <FFToolsPanel onInstalled={() => refresh().catch(() => undefined)} />}
|
{group.key === 'general' && <FFToolsPanel onInstalled={() => refresh().catch(() => undefined)} />}
|
||||||
{group.key === 'about' && <AboutSettingsPanel />}
|
{group.key === 'about' && <AboutSettingsPanel />}
|
||||||
|
{group.key === 'device-notify' && <TelegramNotifyPanel />}
|
||||||
{group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && (
|
{group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && (
|
||||||
<form onSubmit={onSave} className="glass-panel space-y-4">
|
<form onSubmit={onSave} className="glass-panel space-y-4">
|
||||||
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
|
{group.description && <p className="text-xs text-sand-500">{group.description}</p>}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Loader2, Send } from 'lucide-react'
|
||||||
|
|
||||||
|
import { telegramAPI } from '../api/telegram'
|
||||||
|
|
||||||
|
// TelegramNotifyPanel 提供「发送测试消息」按钮。
|
||||||
|
//
|
||||||
|
// 通知通道最容易出的问题是 Token 或 Chat ID 填错,而这类错误平时是完全静默的
|
||||||
|
// (发送失败只写服务端日志)。这个按钮把失败原因直接带回界面。
|
||||||
|
export function TelegramNotifyPanel() {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||||||
|
|
||||||
|
const runTest = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const res = await telegramAPI.test()
|
||||||
|
setResult(
|
||||||
|
res.success
|
||||||
|
? { ok: true, message: '测试消息已发送,请检查 Telegram。' }
|
||||||
|
: { ok: false, message: res.error || '发送失败' },
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
setResult({
|
||||||
|
ok: false,
|
||||||
|
message: err instanceof Error ? err.message : '发送失败',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-panel space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-display text-sm font-bold text-ink-100">通知通道自检</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-sand-500">
|
||||||
|
保存上方设置后点击发送。测试消息会发到「管理员 Chat ID」,用于确认 Token
|
||||||
|
与会话 ID 都正确。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void runTest()}
|
||||||
|
disabled={busy}
|
||||||
|
className="btn-secondary inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
|
||||||
|
<span>发送测试消息</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'text-xs font-semibold ' + (result.ok ? 'text-emerald-400' : 'text-rose-400')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{result.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Clock, Play, Trash2 } from 'lucide-react'
|
import { BarChart3, Clock, Play, Trash2 } from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { historyAPI } from '../api/history'
|
import { historyAPI } from '../api/history'
|
||||||
@@ -77,6 +77,13 @@ export function WatchHistoryPage() {
|
|||||||
<h1 className="font-display text-3xl font-bold text-ink-600">观看历史</h1>
|
<h1 className="font-display text-3xl font-bold text-ink-600">观看历史</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to="/history/stats"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-bold text-gray-600 shadow-sm transition hover:border-brand-300 hover:text-brand-600"
|
||||||
|
>
|
||||||
|
<BarChart3 size={14} />
|
||||||
|
观看统计
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => clearByStatus('incomplete')}
|
onClick={() => clearByStatus('incomplete')}
|
||||||
disabled={busy !== '' || items.every((item) => item.completed)}
|
disabled={busy !== '' || items.every((item) => item.completed)}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { Clock, Film, Loader2, Star, Tv } from 'lucide-react'
|
||||||
|
import { ARTWORK, imageURL } from '../api/client'
|
||||||
|
import { historyAPI } from '../api/history'
|
||||||
|
import type { HistoryStats } from '../types/history'
|
||||||
|
import { PageHeader } from '../components/PageHeader'
|
||||||
|
import { usePermission } from '../hooks/usePermission'
|
||||||
|
|
||||||
|
// WatchStatsPage 展示个人观看统计。
|
||||||
|
//
|
||||||
|
// 只消费已有数据(PlaybackHistory 聚合),因此这里没有任何写操作;图表也用
|
||||||
|
// 纯 CSS 柱状呈现,不引入图表库 —— 30 根柱子的信息量不值得多一个依赖。
|
||||||
|
export function WatchStatsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const canViewHistory = usePermission('can_view_history')
|
||||||
|
const [stats, setStats] = useState<HistoryStats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
|
// Redirect denied users before issuing any API calls.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canViewHistory) {
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
}
|
||||||
|
}, [canViewHistory, navigate])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await historyAPI.stats()
|
||||||
|
setStats(data)
|
||||||
|
setFailed(false)
|
||||||
|
} catch {
|
||||||
|
setFailed(true)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-32 text-[var(--app-muted)]">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed || !stats) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title="观看统计" description="你的观看时长、类型分布与最近记录" />
|
||||||
|
<p className="glass-panel p-6 text-center text-sm text-[var(--app-muted)]">
|
||||||
|
统计加载失败,请稍后重试。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const daily = stats.daily ?? []
|
||||||
|
const byType = stats.by_library_type ?? []
|
||||||
|
const recent = stats.recent ?? []
|
||||||
|
const hasAnyHistory = stats.total > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title="观看统计" description="你的观看时长、类型分布与最近记录" />
|
||||||
|
|
||||||
|
{!hasAnyHistory ? (
|
||||||
|
<p className="glass-panel p-8 text-center text-sm text-[var(--app-muted)]">
|
||||||
|
还没有观看记录。播放任意内容后,这里会展示观看时长与类型分布。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
icon={<Clock size={16} />}
|
||||||
|
label="累计观看"
|
||||||
|
value={formatHours(stats.watched_hours)}
|
||||||
|
hint={`共 ${stats.total} 条播放记录`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Star size={16} />}
|
||||||
|
label="已看完"
|
||||||
|
value={String(stats.completed)}
|
||||||
|
hint={
|
||||||
|
stats.last_watched
|
||||||
|
? `最近观看 ${formatDate(stats.last_watched)}`
|
||||||
|
: '尚无观看时间'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Film size={16} />}
|
||||||
|
label="正在看"
|
||||||
|
value={String(stats.in_progress ?? 0)}
|
||||||
|
hint="未标记看完的条目"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-panel space-y-4">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h2 className="font-display text-lg font-semibold text-[var(--app-text)]">
|
||||||
|
近 30 天观看时长
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-[var(--app-muted)]">
|
||||||
|
{daily.length > 0 ? `${daily.length} 天有记录` : '暂无记录'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{daily.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-[var(--app-muted)]">
|
||||||
|
近 30 天没有观看记录
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<DailyBars daily={daily} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-panel space-y-3">
|
||||||
|
<h2 className="font-display text-lg font-semibold text-[var(--app-text)]">
|
||||||
|
类型分布
|
||||||
|
</h2>
|
||||||
|
{byType.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-sm text-[var(--app-muted)]">暂无数据</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{byType.map((row) => {
|
||||||
|
const max = Math.max(...byType.map((item) => item.watch_ms), 1)
|
||||||
|
const ratio = Math.max(2, Math.round((row.watch_ms / max) * 100))
|
||||||
|
return (
|
||||||
|
<li key={row.type} className="flex items-center gap-3">
|
||||||
|
<span className="flex w-20 shrink-0 items-center gap-1.5 text-xs font-semibold text-[var(--app-subtle)]">
|
||||||
|
{typeIcon(row.type)}
|
||||||
|
{typeLabel(row.type)}
|
||||||
|
</span>
|
||||||
|
<span className="h-2.5 flex-1 overflow-hidden rounded-full bg-[var(--app-hover)]">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-brand-500"
|
||||||
|
style={{ width: `${ratio}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="w-24 shrink-0 text-right font-mono text-xs text-[var(--app-muted)]">
|
||||||
|
{formatHours(row.watch_ms / 1000 / 3600)} · {row.count} 条
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<section className="glass-panel space-y-3">
|
||||||
|
<h2 className="font-display text-lg font-semibold text-[var(--app-text)]">
|
||||||
|
最近看过
|
||||||
|
</h2>
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{recent.map((row) => (
|
||||||
|
<li key={row.history.id}>
|
||||||
|
<Link
|
||||||
|
to={`/media/${row.history.media_id}`}
|
||||||
|
className="flex items-center gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2.5 transition-colors hover:bg-[var(--app-hover)]"
|
||||||
|
>
|
||||||
|
<span className="h-14 w-10 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||||
|
{row.media?.poster_url && (
|
||||||
|
<img
|
||||||
|
src={imageURL(
|
||||||
|
row.media.poster_url,
|
||||||
|
row.media.updated_at,
|
||||||
|
ARTWORK.posterTiny,
|
||||||
|
)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-xs font-semibold text-[var(--app-text)]">
|
||||||
|
{row.media?.title ?? '未知媒体'}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block text-[11px] text-[var(--app-muted)]">
|
||||||
|
{formatDate(row.history.watched_at)}
|
||||||
|
{row.history.completed ? ' · 已看完' : ' · 在看'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
hint: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="glass-panel space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-[var(--app-muted)]">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="font-display text-2xl font-black text-[var(--app-text)]">{value}</div>
|
||||||
|
<div className="text-[11px] text-[var(--app-muted)]">{hint}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DailyBars 用纯 CSS 画 30 天柱状图。柱高按区间内最大值归一化,最小值留 4%
|
||||||
|
// 高度,否则「有记录但很少」的那天在视觉上会消失。
|
||||||
|
function DailyBars({
|
||||||
|
daily,
|
||||||
|
}: {
|
||||||
|
daily: Array<{ day: string; watch_ms: number; plays: number }>
|
||||||
|
}) {
|
||||||
|
const max = Math.max(...daily.map((item) => item.watch_ms), 1)
|
||||||
|
return (
|
||||||
|
<div className="flex h-32 items-end gap-1">
|
||||||
|
{daily.map((item) => {
|
||||||
|
const ratio = Math.max(4, Math.round((item.watch_ms / max) * 100))
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.day}
|
||||||
|
className="group relative flex-1 rounded-t bg-brand-500/70 transition-colors hover:bg-brand-500"
|
||||||
|
style={{ height: `${ratio}%` }}
|
||||||
|
title={`${item.day} · ${formatHours(item.watch_ms / 1000 / 3600)} · ${item.plays} 条`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHours(hours: number): string {
|
||||||
|
if (!Number.isFinite(hours) || hours <= 0) return '0 小时'
|
||||||
|
if (hours < 1) {
|
||||||
|
const minutes = Math.round(hours * 60)
|
||||||
|
return `${minutes} 分钟`
|
||||||
|
}
|
||||||
|
return `${hours.toFixed(hours >= 10 ? 0 : 1)} 小时`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
return date.toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
movie: '电影',
|
||||||
|
tv: '剧集',
|
||||||
|
anime: '动漫',
|
||||||
|
variety: '综艺',
|
||||||
|
music: '音乐',
|
||||||
|
adult: 'Adult',
|
||||||
|
}
|
||||||
|
return labels[type] ?? '其他'
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeIcon(type: string) {
|
||||||
|
if (type === 'tv' || type === 'anime' || type === 'variety') {
|
||||||
|
return <Tv size={13} />
|
||||||
|
}
|
||||||
|
return <Film size={13} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { SettingGroup } from './settingsGroupTypes'
|
||||||
|
|
||||||
|
// 设备管控与通知设置。
|
||||||
|
//
|
||||||
|
// 这些键原先只能通过 Telegram Bot 命令调整,网页端没有任何入口;本分组把它们
|
||||||
|
// 暴露成可维护的表单,并解释每个值的实际作用。
|
||||||
|
export const deviceNotifySettingsGroup: SettingGroup = {
|
||||||
|
key: 'device-notify',
|
||||||
|
label: '设备与通知',
|
||||||
|
description: '登录设备管控、防共享策略与 Telegram 通知通道',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'telegram.enabled',
|
||||||
|
label: '启用 Telegram 通知',
|
||||||
|
type: 'toggle',
|
||||||
|
defaultValue: 'false',
|
||||||
|
hint: '总开关。关闭时所有通知静默跳过,不影响任何业务流程。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'telegram.bot_token',
|
||||||
|
label: 'Bot Token',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '123456:AA...',
|
||||||
|
hint: 'BotFather 发放的 Token。保存后再次打开会显示为脱敏值;保持脱敏值不动即表示不修改。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'telegram.admin_chat_id',
|
||||||
|
label: '管理员 Chat ID',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '例如 123456789',
|
||||||
|
hint: '接收运维通知(任务失败、设备策略动作)的会话 ID。可先用 Bot 给管理员发消息,再从日志或 getUpdates 获取。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'device.antishare_enabled',
|
||||||
|
label: '启用防共享',
|
||||||
|
type: 'toggle',
|
||||||
|
defaultValue: 'false',
|
||||||
|
hint: '开启后,超过并发播放/登录终端上限会禁用账号;设备指纹变化按警告累计处理。默认关闭。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'device.max_concurrent_play',
|
||||||
|
label: '最大并发播放设备',
|
||||||
|
type: 'number',
|
||||||
|
defaultValue: '3',
|
||||||
|
hint: '同一账号在判定窗口内同时在线的播放设备数上限。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'device.max_logged_clients',
|
||||||
|
label: '最大同时登录终端',
|
||||||
|
type: 'number',
|
||||||
|
defaultValue: '3',
|
||||||
|
hint: '活跃天数窗口内允许的登录终端数量上限。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'device.warn_threshold',
|
||||||
|
label: '设备指纹警告阈值',
|
||||||
|
type: 'number',
|
||||||
|
defaultValue: '2',
|
||||||
|
hint: '设备指纹异常累计超过该次数后禁用账号。警告会同步通知用户本人。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'device.play_window_seconds',
|
||||||
|
label: '并发判定窗口(秒)',
|
||||||
|
type: 'number',
|
||||||
|
defaultValue: '90',
|
||||||
|
hint: '统计「同时播放」时回看的时间窗口。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'device.client_active_days',
|
||||||
|
label: '登录设备活跃天数',
|
||||||
|
type: 'number',
|
||||||
|
defaultValue: '30',
|
||||||
|
hint: '统计「同时登录终端」时回看的天数窗口。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { adultSettingsGroup } from './settingsGroupAccess'
|
import { adultSettingsGroup } from './settingsGroupAccess'
|
||||||
import { apiConfigsSettingsGroup } from './settingsGroupAPIConfigs'
|
import { apiConfigsSettingsGroup } from './settingsGroupAPIConfigs'
|
||||||
|
import { deviceNotifySettingsGroup } from './settingsGroupDeviceNotify'
|
||||||
import { generalSettingsGroup } from './settingsGroupGeneral'
|
import { generalSettingsGroup } from './settingsGroupGeneral'
|
||||||
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
|
import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords'
|
||||||
import type { SettingGroup } from './settingsGroupTypes'
|
import type { SettingGroup } from './settingsGroupTypes'
|
||||||
@@ -22,6 +23,7 @@ export const aboutSettingsGroup: SettingGroup = {
|
|||||||
|
|
||||||
export const GROUPS: SettingGroup[] = [
|
export const GROUPS: SettingGroup[] = [
|
||||||
generalSettingsGroup,
|
generalSettingsGroup,
|
||||||
|
deviceNotifySettingsGroup,
|
||||||
databaseSettingsGroup,
|
databaseSettingsGroup,
|
||||||
apiConfigsSettingsGroup,
|
apiConfigsSettingsGroup,
|
||||||
recognitionWordsSettingsGroup,
|
recognitionWordsSettingsGroup,
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { libraryAPI } from '../api/library'
|
|||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
import { peekLibrary, resolveLibrary } from '../utils/libraryCache'
|
import { peekLibrary, resolveLibrary } from '../utils/libraryCache'
|
||||||
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
|
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
|
||||||
|
import {
|
||||||
|
EMPTY_LIBRARY_FILTERS,
|
||||||
|
serializeLibraryFilters,
|
||||||
|
type LibraryFilterParams,
|
||||||
|
} from '../utils/libraryFilters'
|
||||||
import type { SortField, SortOrder } from '../utils/mediaSort'
|
import type { SortField, SortOrder } from '../utils/mediaSort'
|
||||||
import { MAX_RESTORE_PAGES, readListPosition, writeListPosition } from '../hooks/useListPositionMemory'
|
import { MAX_RESTORE_PAGES, readListPosition, writeListPosition } from '../hooks/useListPositionMemory'
|
||||||
|
|
||||||
@@ -13,6 +18,7 @@ export function useLibraryData(
|
|||||||
selectedSeries: SeriesCard | null,
|
selectedSeries: SeriesCard | null,
|
||||||
sortField: SortField,
|
sortField: SortField,
|
||||||
sortOrder: SortOrder,
|
sortOrder: SortOrder,
|
||||||
|
filters: LibraryFilterParams = EMPTY_LIBRARY_FILTERS,
|
||||||
) {
|
) {
|
||||||
const [library, setLibrary] = useState<Library | null>(null)
|
const [library, setLibrary] = useState<Library | null>(null)
|
||||||
const [items, setItems] = useState<Media[]>([])
|
const [items, setItems] = useState<Media[]>([])
|
||||||
@@ -44,9 +50,10 @@ export function useLibraryData(
|
|||||||
const modeRef = useRef<'media' | 'series'>('media')
|
const modeRef = useRef<'media' | 'series'>('media')
|
||||||
const moreInFlightRef = useRef(false)
|
const moreInFlightRef = useRef(false)
|
||||||
|
|
||||||
// 分页位置按「媒体库 + 排序」记忆:详情页返回、甚至换完排序再切回来,
|
// 分页位置按「媒体库 + 排序 + 筛选」记忆:详情页返回、甚至换完排序或筛选后
|
||||||
// 都能回到上次加载到的页数。
|
// 再切回来,都能回到上次加载到的页数。
|
||||||
const positionKey = `library:${libraryID}:${sortField}:${sortOrder}`
|
const filterKey = serializeLibraryFilters(filters)
|
||||||
|
const positionKey = `library:${libraryID}:${sortField}:${sortOrder}:${filterKey}`
|
||||||
|
|
||||||
// 拉取并追加下一页。滚动哨兵、按钮和首屏分页恢复共用这一条路径,
|
// 拉取并追加下一页。滚动哨兵、按钮和首屏分页恢复共用这一条路径,
|
||||||
// 避免两套分页逻辑各自算页码。
|
// 避免两套分页逻辑各自算页码。
|
||||||
@@ -60,6 +67,7 @@ export function useLibraryData(
|
|||||||
const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib), {
|
const data = await libraryAPI.listSeries(libraryID, page, pageSizeFor(lib), {
|
||||||
sort: sortField,
|
sort: sortField,
|
||||||
order: sortOrder,
|
order: sortOrder,
|
||||||
|
filters,
|
||||||
})
|
})
|
||||||
if (seq !== requestSeqRef.current) return false
|
if (seq !== requestSeqRef.current) return false
|
||||||
const pageItems = data.items ?? []
|
const pageItems = data.items ?? []
|
||||||
@@ -72,6 +80,7 @@ export function useLibraryData(
|
|||||||
const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib), {
|
const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib), {
|
||||||
sort: sortField,
|
sort: sortField,
|
||||||
order: sortOrder,
|
order: sortOrder,
|
||||||
|
filters,
|
||||||
})
|
})
|
||||||
if (seq !== requestSeqRef.current) return false
|
if (seq !== requestSeqRef.current) return false
|
||||||
const pageItems = data.items ?? []
|
const pageItems = data.items ?? []
|
||||||
@@ -93,7 +102,7 @@ export function useLibraryData(
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}, [libraryID, positionKey, sortField, sortOrder])
|
}, [libraryID, positionKey, sortField, sortOrder, filters])
|
||||||
|
|
||||||
const loadMore = useCallback(async (options?: { remember?: boolean }) => {
|
const loadMore = useCallback(async (options?: { remember?: boolean }) => {
|
||||||
if (moreInFlightRef.current || !hasMoreRef.current) return
|
if (moreInFlightRef.current || !hasMoreRef.current) return
|
||||||
@@ -168,14 +177,22 @@ export function useLibraryData(
|
|||||||
const pageSize = pageSizeFor(lib)
|
const pageSize = pageSizeFor(lib)
|
||||||
try {
|
try {
|
||||||
if (seriesMode) {
|
if (seriesMode) {
|
||||||
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
|
const data = await libraryAPI.listSeries(libraryID, 1, pageSize, {
|
||||||
|
sort: sortField,
|
||||||
|
order: sortOrder,
|
||||||
|
filters,
|
||||||
|
})
|
||||||
if (cancelled || seq !== requestSeqRef.current) return
|
if (cancelled || seq !== requestSeqRef.current) return
|
||||||
const pageItems = data.items ?? []
|
const pageItems = data.items ?? []
|
||||||
setServerSeriesCards(pageItems)
|
setServerSeriesCards(pageItems)
|
||||||
loadedCountRef.current = pageItems.length
|
loadedCountRef.current = pageItems.length
|
||||||
totalRef.current = data.total ?? pageItems.length
|
totalRef.current = data.total ?? pageItems.length
|
||||||
} else {
|
} else {
|
||||||
const data = await libraryAPI.listMedia(libraryID, 1, pageSize, { sort: sortField, order: sortOrder })
|
const data = await libraryAPI.listMedia(libraryID, 1, pageSize, {
|
||||||
|
sort: sortField,
|
||||||
|
order: sortOrder,
|
||||||
|
filters,
|
||||||
|
})
|
||||||
if (cancelled || seq !== requestSeqRef.current) return
|
if (cancelled || seq !== requestSeqRef.current) return
|
||||||
const pageItems = data.items ?? []
|
const pageItems = data.items ?? []
|
||||||
setItems(pageItems)
|
setItems(pageItems)
|
||||||
@@ -214,7 +231,7 @@ export function useLibraryData(
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
requestSeqRef.current += 1
|
requestSeqRef.current += 1
|
||||||
}
|
}
|
||||||
}, [appendNextPage, libraryID, positionKey, reloadTick, sortField, sortOrder])
|
}, [appendNextPage, libraryID, positionKey, reloadTick, sortField, sortOrder, filters])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
|
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
|
||||||
|
|||||||
@@ -11,10 +11,30 @@ export interface HistoryItem {
|
|||||||
media?: Media
|
media?: Media
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HistoryDailyStat {
|
||||||
|
day: string
|
||||||
|
watch_ms: number
|
||||||
|
plays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryTypeStat {
|
||||||
|
type: string
|
||||||
|
watch_ms: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface HistoryStats {
|
export interface HistoryStats {
|
||||||
total: number
|
total: number
|
||||||
completed: number
|
completed: number
|
||||||
watched_ms: number
|
watched_ms: number
|
||||||
watched_hours: number
|
watched_hours: number
|
||||||
last_watched?: string
|
last_watched?: string
|
||||||
|
/** 正在看(未标记看完)的条目数。 */
|
||||||
|
in_progress?: number
|
||||||
|
/** 近 30 天中有观看记录的日子,未观看的日期不返回。 */
|
||||||
|
daily?: HistoryDailyStat[]
|
||||||
|
/** 按媒体库类型聚合的观看时长与条目数。 */
|
||||||
|
by_library_type?: HistoryTypeStat[]
|
||||||
|
/** 最近看过的若干条,含 media 详情用于渲染卡片。 */
|
||||||
|
recent?: Array<{ history: HistoryItem; media?: Media }>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// 媒体库筛选条件:解析/序列化为 URL 查询串。
|
||||||
|
//
|
||||||
|
// 放在 URL 而不是组件状态里,是为了让「筛选后的库」可以直接分享、刷新后保持、
|
||||||
|
// 以及用浏览器返回键撤销上一步筛选。
|
||||||
|
|
||||||
|
export type LibraryFilterParams = {
|
||||||
|
genres: string[]
|
||||||
|
year_min?: number
|
||||||
|
year_max?: number
|
||||||
|
rating_min?: number
|
||||||
|
unwatched?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_LIBRARY_FILTERS: LibraryFilterParams = { genres: [] }
|
||||||
|
|
||||||
|
const GENRE_PARAM = 'genre'
|
||||||
|
const YEAR_MIN_PARAM = 'year_min'
|
||||||
|
const YEAR_MAX_PARAM = 'year_max'
|
||||||
|
const RATING_MIN_PARAM = 'rating_min'
|
||||||
|
const UNWATCHED_PARAM = 'unwatched'
|
||||||
|
|
||||||
|
/** 判断是否有任何筛选生效(用于显示「清除筛选」与空态文案)。 */
|
||||||
|
export function hasActiveFilters(filters: LibraryFilterParams): boolean {
|
||||||
|
return (
|
||||||
|
filters.genres.length > 0 ||
|
||||||
|
Boolean(filters.year_min) ||
|
||||||
|
Boolean(filters.year_max) ||
|
||||||
|
Boolean(filters.rating_min) ||
|
||||||
|
Boolean(filters.unwatched)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLibraryFilters(search: string): LibraryFilterParams {
|
||||||
|
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||||
|
const filters: LibraryFilterParams = {
|
||||||
|
genres: params.getAll(GENRE_PARAM).filter((value) => value.trim() !== ''),
|
||||||
|
}
|
||||||
|
const yearMin = Number(params.get(YEAR_MIN_PARAM))
|
||||||
|
if (Number.isFinite(yearMin) && yearMin > 0) filters.year_min = yearMin
|
||||||
|
const yearMax = Number(params.get(YEAR_MAX_PARAM))
|
||||||
|
if (Number.isFinite(yearMax) && yearMax > 0) filters.year_max = yearMax
|
||||||
|
const ratingMin = Number(params.get(RATING_MIN_PARAM))
|
||||||
|
if (Number.isFinite(ratingMin) && ratingMin > 0) filters.rating_min = ratingMin
|
||||||
|
if (isTruthy(params.get(UNWATCHED_PARAM))) filters.unwatched = true
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* serializeLibraryFilters 生成筛选参数的查询串(不含前导 `?`,也不含排序等
|
||||||
|
* 其它参数)。空筛选返回空串。
|
||||||
|
*/
|
||||||
|
export function serializeLibraryFilters(filters: LibraryFilterParams): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
filters.genres.forEach((genre) => {
|
||||||
|
const trimmed = genre.trim()
|
||||||
|
if (trimmed) params.append(GENRE_PARAM, trimmed)
|
||||||
|
})
|
||||||
|
if (filters.year_min) params.set(YEAR_MIN_PARAM, String(filters.year_min))
|
||||||
|
if (filters.year_max) params.set(YEAR_MAX_PARAM, String(filters.year_max))
|
||||||
|
if (filters.rating_min) params.set(RATING_MIN_PARAM, String(filters.rating_min))
|
||||||
|
if (filters.unwatched) params.set(UNWATCHED_PARAM, '1')
|
||||||
|
return params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toFilterQuery 把筛选条件转成 axios params,供列表与随机接口共用。
|
||||||
|
* 键名与服务端解析保持一致。
|
||||||
|
*/
|
||||||
|
export function toFilterQuery(filters: LibraryFilterParams): Record<string, unknown> {
|
||||||
|
const query: Record<string, unknown> = {}
|
||||||
|
if (filters.genres.length > 0) query[GENRE_PARAM] = filters.genres
|
||||||
|
if (filters.year_min) query[YEAR_MIN_PARAM] = filters.year_min
|
||||||
|
if (filters.year_max) query[YEAR_MAX_PARAM] = filters.year_max
|
||||||
|
if (filters.rating_min) query[RATING_MIN_PARAM] = filters.rating_min
|
||||||
|
if (filters.unwatched) query[UNWATCHED_PARAM] = 1
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* withFilterParams 在原查询串上叠加筛选参数,同时保留其它参数(如排序记忆)。
|
||||||
|
*/
|
||||||
|
export function withFilterParams(search: string, filters: LibraryFilterParams): string {
|
||||||
|
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||||
|
params.delete(GENRE_PARAM)
|
||||||
|
params.delete(YEAR_MIN_PARAM)
|
||||||
|
params.delete(YEAR_MAX_PARAM)
|
||||||
|
params.delete(RATING_MIN_PARAM)
|
||||||
|
params.delete(UNWATCHED_PARAM)
|
||||||
|
filters.genres.forEach((genre) => {
|
||||||
|
const trimmed = genre.trim()
|
||||||
|
if (trimmed) params.append(GENRE_PARAM, trimmed)
|
||||||
|
})
|
||||||
|
if (filters.year_min) params.set(YEAR_MIN_PARAM, String(filters.year_min))
|
||||||
|
if (filters.year_max) params.set(YEAR_MAX_PARAM, String(filters.year_max))
|
||||||
|
if (filters.rating_min) params.set(RATING_MIN_PARAM, String(filters.rating_min))
|
||||||
|
if (filters.unwatched) params.set(UNWATCHED_PARAM, '1')
|
||||||
|
const query = params.toString()
|
||||||
|
return query ? `?${query}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* normalizeYearRange 修正用户输入的年份区间:上限早于下限时交换,
|
||||||
|
* 避免一次无心的顺序颠倒把结果筛成空。
|
||||||
|
*/
|
||||||
|
export function normalizeYearRange(
|
||||||
|
min?: number,
|
||||||
|
max?: number,
|
||||||
|
): { year_min?: number; year_max?: number } {
|
||||||
|
if (min && max && min > max) {
|
||||||
|
return { year_min: max, year_max: min }
|
||||||
|
}
|
||||||
|
return { year_min: min, year_max: max }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTruthy(value: string | null): boolean {
|
||||||
|
if (!value) return false
|
||||||
|
const normalized = value.toLowerCase()
|
||||||
|
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'
|
||||||
|
}
|
||||||