diff --git a/docs/forum-post.md b/docs/forum-post.md deleted file mode 100644 index fd27de9..0000000 --- a/docs/forum-post.md +++ /dev/null @@ -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,海报墙开箱即用** - -![登录页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/01-login.png) - -深色系登录页,默认账号 `admin / admin123`(首次登录请立即改密)。 - -![首页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/02-home.png) - -首页自带焦点推荐轮播 + 媒体库入口卡片,继续观看、最近添加直接呈现。 - -**2. 媒体库与刮削** - -![媒体库总览](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/03-libraries.png) - -20 个媒体库、1600+ 条目一眼尽收:每库自带封面拼贴、条目数统计,支持「全库修复+重刮」「刮削队列」批量处理。 - -![海报墙](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/04-library-posters.png) - -库内海报墙带评分、集数角标,支持按最后集添加日期排序,点开即看。 - -**3. 详情页与多季管理** - -![详情页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/05-media-detail.png) - -剧情简介、类型标签、多季分集(特别篇/第 1-N 季)、每集缩略图与时长;一键立即播放、调用外部播放器、加入收藏。 - -**4. Emby/Jellyfin 客户端无缝兼容** - -这是我最想强调的一点:**MeBox 内置了完整的 Emby 服务端协议实现**。手机、电视、平板上的 Infuse、SenPlayer、Fileball,甚至 Emby/Jellyfin 官方客户端,都不需要任何插件或改造——按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录,海报墙、观看进度、收藏、多用户权限全部无缝衔接。已经习惯 Emby 生态的朋友可以零成本迁移,家人只用电视端 App 也完全无感。 - -**5. 网页播放器 + 弹幕自动匹配** - -![播放器与弹幕](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/06-player-danmu.png) - -内置网页播放器支持 HLS 转码、字幕、播放配置档;**弹幕按剧名自动匹配全季分集**(截图中自动匹配到《一拳超人》39 集),屏幕占比/透明度/字号随意调,追新番体验直接拉满。 - -**6. 网盘 STRM:网盘当本地盘用** - -![STRM 管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/07-strm-cloud.png) - -添加网盘账号(**115 支持二维码扫码登录**)→ 添加同步目录 → 系统把网盘/本地目录里的视频生成 `.strm` 文件,元数据经下载/上传队列双向同步,播放走直链/302 不落盘。 - -**7. 远程 Emby 挂载(特色功能)** - -![Emby 挂载](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/08-emby-mount.png) - -已有远程 Emby 服务器?填一次账号,按需勾选要挂载的媒体库(支持同服务器多线路自动切换、直连开关、排序),远程库直接出现在 MeBox 首页,不必再开 Emby 客户端。 - -**8. 任务队列统一管理** - -![任务队列](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/09-task-queue.png) - -刮削 / 下载 / 上传三类任务统一看板,排队中、进行中、已匹配、失败分类计数,支持搜索与批量清理。 - -**9. 下载与自动整理** - -![文件管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/11-file-manager.png) - -配合任意下载器(qBittorrent、Transmission 等下载到本地目录即可),MeBox 定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。 - -**10. 多用户与权限** - -![用户管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/12-user-admin.png) - -管理员/普通用户分级、单实例用户数上限、账号有效期、成人内容开关、播放配置 PIN——给家人开号放心给。 - -**11. 运维省心** - -![系统设置](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/10-settings.png) - -FFmpeg/FFprobe 一键下载安装、转码与硬件加速开关、TMDb 语言、识别词、弹幕、Adult/NSFW 开关全在设置页分组管理;另有 DLNA 投屏、存储统计、海报墙聚合视图: - -![海报墙聚合](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/13-poster-wall.png) - ---- - -## 使用教程:从零到海报墙只要 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 ⭐,也欢迎论坛里的朋友反馈使用体验,我长期维护。 diff --git a/docs/reading-module-design.md b/docs/reading-module-design.md deleted file mode 100644 index d77138a..0000000 --- a/docs/reading-module-design.md +++ /dev/null @@ -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 的 `` 或首个 `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 数组 -``` diff --git a/docs/tutorial-screenshots/01-login.png b/docs/tutorial-screenshots/01-login.png deleted file mode 100644 index 88f3099..0000000 Binary files a/docs/tutorial-screenshots/01-login.png and /dev/null differ diff --git a/docs/tutorial-screenshots/02-home.png b/docs/tutorial-screenshots/02-home.png deleted file mode 100644 index 8893c8e..0000000 Binary files a/docs/tutorial-screenshots/02-home.png and /dev/null differ diff --git a/docs/tutorial-screenshots/03-libraries.png b/docs/tutorial-screenshots/03-libraries.png deleted file mode 100644 index d81681e..0000000 Binary files a/docs/tutorial-screenshots/03-libraries.png and /dev/null differ diff --git a/docs/tutorial-screenshots/04-library-posters.png b/docs/tutorial-screenshots/04-library-posters.png deleted file mode 100644 index 421dcd4..0000000 Binary files a/docs/tutorial-screenshots/04-library-posters.png and /dev/null differ diff --git a/docs/tutorial-screenshots/05-media-detail.png b/docs/tutorial-screenshots/05-media-detail.png deleted file mode 100644 index b5ac092..0000000 Binary files a/docs/tutorial-screenshots/05-media-detail.png and /dev/null differ diff --git a/docs/tutorial-screenshots/06-player-danmu.png b/docs/tutorial-screenshots/06-player-danmu.png deleted file mode 100644 index a4addb5..0000000 Binary files a/docs/tutorial-screenshots/06-player-danmu.png and /dev/null differ diff --git a/docs/tutorial-screenshots/07-strm-cloud.png b/docs/tutorial-screenshots/07-strm-cloud.png deleted file mode 100644 index 2fd8620..0000000 Binary files a/docs/tutorial-screenshots/07-strm-cloud.png and /dev/null differ diff --git a/docs/tutorial-screenshots/08-emby-mount.png b/docs/tutorial-screenshots/08-emby-mount.png deleted file mode 100644 index dca18fe..0000000 Binary files a/docs/tutorial-screenshots/08-emby-mount.png and /dev/null differ diff --git a/docs/tutorial-screenshots/09-task-queue.png b/docs/tutorial-screenshots/09-task-queue.png deleted file mode 100644 index dcdbe93..0000000 Binary files a/docs/tutorial-screenshots/09-task-queue.png and /dev/null differ diff --git a/docs/tutorial-screenshots/10-settings.png b/docs/tutorial-screenshots/10-settings.png deleted file mode 100644 index 1fd51ba..0000000 Binary files a/docs/tutorial-screenshots/10-settings.png and /dev/null differ diff --git a/docs/tutorial-screenshots/11-file-manager.png b/docs/tutorial-screenshots/11-file-manager.png deleted file mode 100644 index 12434c5..0000000 Binary files a/docs/tutorial-screenshots/11-file-manager.png and /dev/null differ diff --git a/docs/tutorial-screenshots/12-user-admin.png b/docs/tutorial-screenshots/12-user-admin.png deleted file mode 100644 index a58a39f..0000000 Binary files a/docs/tutorial-screenshots/12-user-admin.png and /dev/null differ diff --git a/docs/tutorial-screenshots/13-poster-wall.png b/docs/tutorial-screenshots/13-poster-wall.png deleted file mode 100644 index 01570ed..0000000 Binary files a/docs/tutorial-screenshots/13-poster-wall.png and /dev/null differ diff --git a/internal/handler/admin_settings.go b/internal/handler/admin_settings.go index 932745b..b303398 100644 --- a/internal/handler/admin_settings.go +++ b/internal/handler/admin_settings.go @@ -20,6 +20,12 @@ type settingReq struct { Value string `json:"value"` } +// maskedSettingKeys 里的设置值绝不能被完整下发:它们是可用于对外操作的凭据。 +// 下发脱敏值,保存时再靠 isMaskedSettingValue 还原为「保持原值」。 +var maskedSettingKeys = map[string]bool{ + service.SettingTelegramBotToken: true, +} + func listSettingsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.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()}) return } + for i := range settings { + if maskedSettingKeys[settings[i].Key] { + settings[i].Value = service.MaskSecret(settings[i].Value) + } + } c.JSON(http.StatusOK, settings) } } +// isMaskedSettingValue 识别「前端把脱敏值原样提交回来」的情况。此时必须保留 +// 已存的真实值,否则一次保存就会把凭据覆盖成 ***。 +func isMaskedSettingValue(value string) bool { + return strings.Contains(value, "***") +} + func updateSettingHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { var req settingReq @@ -38,6 +55,11 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + // 脱敏值回传 == 用户没改这个凭据,保留库里已存的真实值。 + if maskedSettingKeys[req.Key] && isMaskedSettingValue(req.Value) { + c.Status(http.StatusNoContent) + return + } oldValue := "" if req.Key == service.AdultLibraryIDsSettingKey { oldValue, _ = svc.Repo.Setting.Get(c.Request.Context(), req.Key) diff --git a/internal/handler/devices.go b/internal/handler/devices.go new file mode 100644 index 0000000..28ce32a --- /dev/null +++ b/internal/handler/devices.go @@ -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 "" +} diff --git a/internal/handler/devices_test.go b/internal/handler/devices_test.go new file mode 100644 index 0000000..2725969 --- /dev/null +++ b/internal/handler/devices_test.go @@ -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) + } +} diff --git a/internal/handler/emby_discovery.go b/internal/handler/emby_discovery.go new file mode 100644 index 0000000..1c84f76 --- /dev/null +++ b/internal/handler/emby_discovery.go @@ -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} +} diff --git a/internal/handler/emby_discovery_test.go b/internal/handler/emby_discovery_test.go new file mode 100644 index 0000000..16d34b6 --- /dev/null +++ b/internal/handler/emby_discovery_test.go @@ -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)) + } +} diff --git a/internal/handler/emby_routes.go b/internal/handler/emby_routes.go index 6118735..51df371 100644 --- a/internal/handler/emby_routes.go +++ b/internal/handler/emby_routes.go @@ -196,15 +196,15 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con auth.GET("/Shows/:id/Episodes", embyShowEpisodesHandler(svc)) auth.GET("/Users/:userId/Shows/:id/Seasons", embyShowSeasonsHandler(svc)) auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc)) - auth.GET("/Shows/NextUp", embyEmptyItemsHandler(svc)) - auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc)) + auth.GET("/Shows/NextUp", embyNextUpHandler(svc)) + auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc)) auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc)) auth.GET("/Artists", 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("/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/ThemeMedia", embyThemeMediaHandler(svc)) auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc)) diff --git a/internal/handler/emby_routes_lowercase.go b/internal/handler/emby_routes_lowercase.go index 2b5370a..8d3a432 100644 --- a/internal/handler/emby_routes_lowercase.go +++ b/internal/handler/emby_routes_lowercase.go @@ -40,15 +40,15 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc)) auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc)) auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc)) - auth.GET("/shows/nextup", embyEmptyItemsHandler(svc)) - auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc)) + auth.GET("/shows/nextup", embyNextUpHandler(svc)) + auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc)) auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc)) auth.GET("/artists", 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("/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/thememedia", embyThemeMediaHandler(svc)) auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc)) diff --git a/internal/handler/library_discovery.go b/internal/handler/library_discovery.go new file mode 100644 index 0000000..02ec88c --- /dev/null +++ b/internal/handler/library_discovery.go @@ -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) + } +} diff --git a/internal/handler/library_filters_test.go b/internal/handler/library_filters_test.go new file mode 100644 index 0000000..3cd1f70 --- /dev/null +++ b/internal/handler/library_filters_test.go @@ -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) + } +} diff --git a/internal/handler/media.go b/internal/handler/media.go index d2272e7..1a6bc65 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -4,6 +4,7 @@ package handler import ( "context" "errors" + "math" "net/http" "strconv" "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 { return func(c *gin.Context) { id := c.Param("id") @@ -450,9 +537,10 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc { if sortSpec.Field == "last_played" { history = mediaHistoryMap(c, svc) } + filters := parseLibraryFilters(c) groupVersions := c.DefaultQuery("group_versions", "1") != "0" 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 { writeInternalOrCanceled(c, err) return @@ -468,7 +556,7 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc { }) 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 { writeInternalOrCanceled(c, err) return diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index 20b21b6..e655d63 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -116,8 +116,13 @@ func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc)) admin.PATCH("/users/:id/libraries", updateUserLibrariesHandler(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.PUT("/settings", updateSettingHandler(svc)) + admin.POST("/telegram/test", testTelegramHandler(svc)) admin.POST("/adult/test-scraper", testAdultScraperHandler(svc)) admin.GET("/logs", recentLogsHandler(svc)) } diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index 72ed820..74e6356 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -19,6 +19,16 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co authed.GET("/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)) } @@ -38,6 +48,8 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(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/episodes", listLibrarySeriesEpisodesHandler(svc)) authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc)) diff --git a/internal/handler/routes_authenticated_extras.go b/internal/handler/routes_authenticated_extras.go index 2ab6801..eddabd2 100644 --- a/internal/handler/routes_authenticated_extras.go +++ b/internal/handler/routes_authenticated_extras.go @@ -15,7 +15,7 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(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.DELETE("/watch-history", historyDeleteHandler(svc)) authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc)) diff --git a/internal/handler/series.go b/internal/handler/series.go index 314bc8e..facc080 100644 --- a/internal/handler/series.go +++ b/internal/handler/series.go @@ -124,7 +124,9 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc { 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 { writeInternalOrCanceled(c, err) return diff --git a/internal/handler/telegram_me.go b/internal/handler/telegram_me.go new file mode 100644 index 0000000..6f6e928 --- /dev/null +++ b/internal/handler/telegram_me.go @@ -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}) + } +} diff --git a/internal/handler/watch_history.go b/internal/handler/watch_history.go index 2068ff8..af94923 100644 --- a/internal/handler/watch_history.go +++ b/internal/handler/watch_history.go @@ -11,8 +11,11 @@ package handler import ( + "context" "net/http" + "sort" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -48,11 +51,16 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc { } // 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 { return func(c *gin.Context) { uid, _ := c.Get(middleware.CtxUserID) userID := toString(uid) + ctx := c.Request.Context() var total int64 _ = svc.Repo.DB.Model(&model.PlaybackHistory{}). @@ -77,16 +85,196 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc { 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{ - "total": total, - "completed": completed, - "watched_ms": watchedMs, - "watched_hours": float64(watchedMs) / 1000.0 / 3600.0, - "last_watched": last, + "total": total, + "completed": completed, + "in_progress": inProgress, + "watched_ms": watchedMs, + "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 // items, most recent first. func historyContinueHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/watch_history_stats_test.go b/internal/handler/watch_history_stats_test.go new file mode 100644 index 0000000..d736cba --- /dev/null +++ b/internal/handler/watch_history_stats_test.go @@ -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) + } +} diff --git a/internal/model/user.go b/internal/model/user.go index f25da0a..e1f5e0a 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -55,6 +55,9 @@ type User struct { // expires. When set and in the past, the account is treated as expired // (login blocked) until an admin or a redemption code renews it. 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 // fingerprint mismatches. Once it exceeds the configured threshold a // re-offence disables the account until an admin re-enables it. diff --git a/internal/model/user_telegram_test.go b/internal/model/user_telegram_test.go new file mode 100644 index 0000000..3609541 --- /dev/null +++ b/internal/model/user_telegram_test.go @@ -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) + } +} diff --git a/internal/repository/media_filter_test.go b/internal/repository/media_filter_test.go new file mode 100644 index 0000000..3fbf732 --- /dev/null +++ b/internal/repository/media_filter_test.go @@ -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) + } +} diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index 2f5c186..22a6512 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -44,6 +44,21 @@ type MediaQueryFilter struct { AllowedLibraryIDs []string HiddenLibraryIDs []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 { @@ -56,12 +71,113 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB { 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) + } if seriesID := strings.TrimSpace(filter.SeriesID); 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 } +// 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) { backend, ok := r.searchBackend.(MediaSearchSyncBackend) if !ok { diff --git a/internal/service/device_listing.go b/internal/service/device_listing.go index 7697605..061f935 100644 --- a/internal/service/device_listing.go +++ b/internal/service/device_listing.go @@ -21,14 +21,24 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string) return fmt.Errorf("device not found") } 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. 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. diff --git a/internal/service/device_notify_test.go b/internal/service/device_notify_test.go new file mode 100644 index 0000000..53d6e42 --- /dev/null +++ b/internal/service/device_notify_test.go @@ -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]) + } +} diff --git a/internal/service/device_service.go b/internal/service/device_service.go index ab2427a..07bfe8b 100644 --- a/internal/service/device_service.go +++ b/internal/service/device_service.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "html" "strings" "time" @@ -34,6 +35,10 @@ type DeviceService struct { // notifyUser sends a Telegram message to the local user (resolved to their // Telegram binding). Wired by the bot service; nil disables notifications. 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. @@ -46,6 +51,11 @@ func (s *DeviceService) SetNotifier(fn func(ctx context.Context, userID, text st 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) { s.sessions = tracker } @@ -102,6 +112,8 @@ func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, devic FirstSeenAt: now, LastSeenAt: now, }) + // 新终端首次登录才通知:已知设备重复登录不刷屏。 + s.notify(ctx, userID, fmt.Sprintf("🔔 新设备登录:<b>%s</b>\n如果这不是你本人,请到「个人资料 → 我的设备」踢下线并修改密码。", deviceLabel(deviceName, client))) } else { if existing.Fingerprint != "" && existing.Fingerprint != fp { mismatch = true @@ -236,7 +248,10 @@ func (s *DeviceService) registerFingerprintWarning(ctx context.Context, userID, "last_share_warn_at": &now, }) 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)) } @@ -255,7 +270,10 @@ func (s *DeviceService) disableForPolicy(ctx context.Context, userID, reason str "last_share_warn_at": &now, }) _ = 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)) } @@ -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 { name = strings.TrimSpace(name) client = strings.TrimSpace(client) diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index e1f14b1..15fe4e5 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -76,6 +76,10 @@ type EmbyService struct { adult *AdultProvider personImageMu sync.RWMutex personImages map[string]string + + // discovery 提供 NextUp / Similar / Genres 的候选集。它只选候选, + // DTO 形状仍由本服务统一产出,避免同一部剧在不同接口上长得不一样。 + discovery *MediaDiscoveryService } // NewEmbyService is the constructor. @@ -91,6 +95,26 @@ func (e *EmbyService) SetEmbyRemote(remote *EmbyRemoteService) *EmbyService { 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. func (e *EmbyService) SetTMDbProvider(tmdb *TMDbProvider) *EmbyService { if e != nil { diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index 500d505..3388e3d 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -1,6 +1,8 @@ package service import ( + "context" + "strings" "testing" "time" @@ -65,3 +67,133 @@ func TestEmbyLatestItemsOrderByReleaseDate(t *testing.T) { 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) + } +} diff --git a/internal/service/emby_discovery.go b/internal/service/emby_discovery.go new file mode 100644 index 0000000..8e7e43e --- /dev/null +++ b/internal/service/emby_discovery.go @@ -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] +} diff --git a/internal/service/media_cache.go b/internal/service/media_cache.go index 485b4d6..52731e9 100644 --- a/internal/service/media_cache.go +++ b/internal/service/media_cache.go @@ -32,10 +32,26 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string, strings.Join(allowed, ","), strings.Join(hidden, ","), filter.SeriesID, + filterFingerprint(filter), }, "|"))) 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 { libIDs := make([]string, len(libraries)) for i, lib := range libraries { @@ -138,6 +154,7 @@ func (s *MediaService) groupedItemsCacheKey(libraryID string, libraryIDs []strin strings.Join(allowed, ","), strings.Join(hidden, ","), filter.SeriesID, + filterFingerprint(filter), }) } diff --git a/internal/service/media_discovery.go b/internal/service/media_discovery.go new file mode 100644 index 0000000..6c8db0a --- /dev/null +++ b/internal/service/media_discovery.go @@ -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 +} diff --git a/internal/service/media_discovery_nextup_test.go b/internal/service/media_discovery_nextup_test.go new file mode 100644 index 0000000..0a164c1 --- /dev/null +++ b/internal/service/media_discovery_nextup_test.go @@ -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) + } +} diff --git a/internal/service/media_discovery_similar_test.go b/internal/service/media_discovery_similar_test.go new file mode 100644 index 0000000..a10651a --- /dev/null +++ b/internal/service/media_discovery_similar_test.go @@ -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 + } +} diff --git a/internal/service/media_discovery_test.go b/internal/service/media_discovery_test.go new file mode 100644 index 0000000..aadb110 --- /dev/null +++ b/internal/service/media_discovery_test.go @@ -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) + } +} diff --git a/internal/service/media_facets.go b/internal/service/media_facets.go new file mode 100644 index 0000000..c4e6327 --- /dev/null +++ b/internal/service/media_facets.go @@ -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 +} diff --git a/internal/service/media_filters_test.go b/internal/service/media_filters_test.go new file mode 100644 index 0000000..68ce6a0 --- /dev/null +++ b/internal/service/media_filters_test.go @@ -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") + } +} diff --git a/internal/service/media_listing.go b/internal/service/media_listing.go index 11cc696..077a29f 100644 --- a/internal/service/media_listing.go +++ b/internal/service/media_listing.go @@ -12,11 +12,63 @@ import ( ) // 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) { 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) { + 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 { pageSize = 50 } @@ -31,11 +83,11 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p if err != nil { return nil, 0, err } - filter := repository.MediaQueryFilter{ + filter := filters.apply(repository.MediaQueryFilter{ IncludeNSFW: visibility.IncludeNSFW, AllowedLibraryIDs: visibility.AllowedLibraryIDs, HiddenLibraryIDs: visibility.HiddenLibraryIDs, - } + }) cacheKey := s.mediaListCacheKey(libraryID, libraryIDs, page, pageSize, filter) var cached mediaListCacheValue 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) { - page, pageSize = normalizeGroupedMediaPage(page, pageSize) grouped, err := s.GroupedMediaVisible(ctx, libraryID, visibility) if err != nil { 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. // 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) { + 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) libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID) if err != nil { return nil, err } - filter := repository.MediaQueryFilter{ + filter := filters.apply(repository.MediaQueryFilter{ IncludeNSFW: visibility.IncludeNSFW, AllowedLibraryIDs: visibility.AllowedLibraryIDs, HiddenLibraryIDs: visibility.HiddenLibraryIDs, - } + }) itemsCacheKey := s.groupedItemsCacheKey(libraryID, libraryIDs, filter) value, err, _ := s.groupedMediaFlight.Do(itemsCacheKey, func() (any, error) { 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) defer cancel() - items, err := s.listMediaVisibleForGrouping(loadCtx, libraryID, visibility) + items, err := s.listMediaVisibleForGrouping(loadCtx, libraryID, visibility, filters) if err != nil { return nil, err } @@ -105,17 +169,17 @@ func (s *MediaService) GroupedMediaVisible(ctx context.Context, libraryID string 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) libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, libraryID) if err != nil { return nil, err } - filter := repository.MediaQueryFilter{ + filter := filters.apply(repository.MediaQueryFilter{ IncludeNSFW: visibility.IncludeNSFW, AllowedLibraryIDs: visibility.AllowedLibraryIDs, HiddenLibraryIDs: visibility.HiddenLibraryIDs, - } + }) // 版本分组的 URL 分页发生在 Go 进程内,响应里的 total 是分组后的数量, // 不需要数据库再为原始行做一次 COUNT(*)。全量 COUNT 在超大媒体库上 // 会重复扫描整个 library_id 范围,而这里只关心是否存在截断风险。 @@ -133,8 +197,7 @@ func (s *MediaService) listMediaVisibleForGrouping(ctx context.Context, libraryI } // GetMedia returns a single media row. -func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) { - media, err := s.repo.Media.FindByID(ctx, id) +func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) { media, err := s.repo.Media.FindByID(ctx, id) if err != nil || media == nil { return media, err } diff --git a/internal/service/media_series.go b/internal/service/media_series.go index 4d29129..376116c 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -147,6 +147,98 @@ func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID str 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) { if limit <= 0 { limit = 24 diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index 050531e..0386831 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -36,6 +36,7 @@ type SchedulerService struct { organizePipeline *OrganizePipelineService hub *Hub tasks *TaskTrackerService + expiryWatcher *TelegramExpiryWatcher cacheDir string now func() time.Time @@ -59,6 +60,11 @@ func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService s.organizePipeline = pipeline } +// SetExpiryWatcher 注入账号到期巡检。未注入时(例如测试)该任务不注册。 +func (s *SchedulerService) SetExpiryWatcher(watcher *TelegramExpiryWatcher) { + s.expiryWatcher = watcher +} + // ImageCachePolicy 是一次图片缓存清理要用的策略(全部为 0 表示不做任何清理)。 type ImageCachePolicy struct { TotalBytes int64 @@ -139,6 +145,14 @@ func (s *SchedulerService) Start(ctx context.Context) { 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 { initialDelay := 15 * time.Second if j.name == "library_scan" || j.name == "organize_source" { diff --git a/internal/service/scheduler_local_jobs.go b/internal/service/scheduler_local_jobs.go index 107f476..b799121 100644 --- a/internal/service/scheduler_local_jobs.go +++ b/internal/service/scheduler_local_jobs.go @@ -169,6 +169,14 @@ func (s *SchedulerService) organizeSourceInterval(ctx context.Context) time.Dura 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. func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error { if s.cacheDir == "" { diff --git a/internal/service/service.go b/internal/service/service.go index 4490e82..fff8202 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -59,6 +59,9 @@ type Container struct { Token *TokenService ApiConfig *ApiConfigService Device *DeviceService + Telegram *TelegramService + TelegramExpiry *TelegramExpiryWatcher + Discovery *MediaDiscoveryService Cache *RuntimeCacheService Sessions *SessionTrackerService RecognitionWords *RecognitionWordsService @@ -103,6 +106,11 @@ func (c *Container) Boot() { // 启动调度器定时任务 c.Scheduler.Start(c.stopCtx) + // Telegram 通知轮询(未启用或未配置 Token 时直接返回) + if c.Telegram != nil { + c.Telegram.Start(c.stopCtx) + } + // 远程 Emby 挂载兼容迁移:清理已删账号的残留挂载;旧账号无挂载时自动全量挂载 if c.EmbyRemote != nil { c.EmbyRemote.CleanupOrphanMounts(c.stopCtx) diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index f07d6a3..901fa3e 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -42,11 +42,22 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository. builder.initContentServices() builder.initAccessAndStorageServices() builder.initIdentityServices() + // Telegram 在 initIdentityServices 里构建,这里把失败告警接到任务状态机上。 + builder.wireTaskNotifications() builder.initImageProxy() builder.attachRuntimeContext() 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() { b.c.WSHub = NewHub(b.log) 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.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) + // 发现类查询(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.Emby.SetEmbyRemote(b.c.EmbyRemote) 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.Emby.SetRuntimeCache(b.c.Cache) b.c.Emby.SetSubtitleService(b.c.Subtitle) + b.c.Emby.SetDiscovery(b.c.Discovery) b.c.Scheduler = NewSchedulerService( b.log, b.repos, b.c.Scan, b.c.Transcoder, 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.Device = NewDeviceService(b.log, b.repos) 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) } diff --git a/internal/service/task_tracker.go b/internal/service/task_tracker.go index 7f079fe..88a6cd7 100644 --- a/internal/service/task_tracker.go +++ b/internal/service/task_tracker.go @@ -1,6 +1,9 @@ package service import ( + "context" + "html" + "strings" "sync" "time" @@ -59,6 +62,9 @@ type TaskTrackerService struct { log *zap.Logger hub *Hub + // failureNotifier 在任务以失败收尾时通知管理员(Telegram)。nil 时静默。 + failureNotifier func(ctx context.Context, text string) + mu sync.Mutex active map[string]*BackgroundTask recent []BackgroundTask @@ -179,8 +185,48 @@ func (t *TaskTrackerService) finish(id string, err error, update TaskUpdate) { if len(t.recent) > t.maxRecent { t.recent = t.recent[:t.maxRecent] } + notifier := t.failureNotifier t.mu.Unlock() 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 { diff --git a/internal/service/telegram.go b/internal/service/telegram.go new file mode 100644 index 0000000..2cf8566 --- /dev/null +++ b/internal/service/telegram.go @@ -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 + } +} diff --git a/internal/service/telegram_alerts_test.go b/internal/service/telegram_alerts_test.go new file mode 100644 index 0000000..48eb448 --- /dev/null +++ b/internal/service/telegram_alerts_test.go @@ -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) + } +} diff --git a/internal/service/telegram_expiry.go b/internal/service/telegram_expiry.go new file mode 100644 index 0000000..460f70c --- /dev/null +++ b/internal/service/telegram_expiry.go @@ -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 +} diff --git a/internal/service/telegram_test.go b/internal/service/telegram_test.go new file mode 100644 index 0000000..b72be6a --- /dev/null +++ b/internal/service/telegram_test.go @@ -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") + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 135b898..66f1a7b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -8,6 +8,10 @@ import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/ export const api = axios.create({ baseURL: '/api', 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 diff --git a/web/src/api/devices.ts b/web/src/api/devices.ts new file mode 100644 index 0000000..4d41f6f --- /dev/null +++ b/web/src/api/devices.ts @@ -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), +} diff --git a/web/src/api/library.ts b/web/src/api/library.ts index 84970fb..3455aa1 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -1,6 +1,18 @@ import { api, BATCH_REQUEST_TIMEOUT, LONG_REQUEST_TIMEOUT } from './client' import type { Library, LibraryRoot, Media, PlaybackInfo, ScanResult } from '../types' 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 { items: Media[] @@ -165,7 +177,12 @@ export const libraryAPI = { id: string, page = 1, pageSize = 50, - options?: { groupVersions?: boolean; sort?: string; order?: 'asc' | 'desc' }, + options?: { + groupVersions?: boolean + sort?: string + order?: 'asc' | 'desc' + filters?: LibraryFilterParams + }, ) => api .get<MediaPage>(`/libraries/${id}/media`, { @@ -175,20 +192,46 @@ export const libraryAPI = { group_versions: options?.groupVersions === false ? 0 : undefined, sort: options?.sort, order: options?.order, + ...toFilterQuery(options?.filters ?? EMPTY_LIBRARY_FILTERS), }, timeout: LONG_REQUEST_TIMEOUT, }) .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: ( id: string, page = 1, pageSize = 500, - options?: { sort?: string; order?: 'asc' | 'desc' }, + options?: { + sort?: string + order?: 'asc' | 'desc' + filters?: LibraryFilterParams + }, ) => api .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, }) .then((r) => r.data), diff --git a/web/src/api/stats.ts b/web/src/api/stats.ts new file mode 100644 index 0000000..33dd36a --- /dev/null +++ b/web/src/api/stats.ts @@ -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 ?? []), +} diff --git a/web/src/api/telegram.ts b/web/src/api/telegram.ts new file mode 100644 index 0000000..61c7620 --- /dev/null +++ b/web/src/api/telegram.ts @@ -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), +} diff --git a/web/src/appRoutes.tsx b/web/src/appRoutes.tsx index 9815a12..8556b12 100644 --- a/web/src/appRoutes.tsx +++ b/web/src/appRoutes.tsx @@ -14,6 +14,8 @@ const MediaDetailPageLoader = () => const PlayerPageLoader = () => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })) const WatchHistoryPageLoader = () => import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage })) +const WatchStatsPageLoader = () => + import('./pages/WatchStatsPage').then((m) => ({ default: m.WatchStatsPage })) const HomePage = lazy(HomePageLoader) const LibraryPage = lazy(LibraryPageLoader) @@ -30,6 +32,7 @@ const FileManagerPage = lazy(() => import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })), ) const WatchHistoryPage = lazy(WatchHistoryPageLoader) +const WatchStatsPage = lazy(WatchStatsPageLoader) const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage }))) const ProfileManagementPage = lazy(() => import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })), @@ -105,6 +108,7 @@ export const appRoutes: AppRoute[] = [ { path: 'profile', element: <ProfilePage /> }, { path: 'dlna', element: <DlnaPage /> }, { path: 'history', element: <WatchHistoryPage /> }, + { path: 'history/stats', element: <WatchStatsPage /> }, { path: 'poster-wall', element: <PosterWallPage /> }, { path: 'play-profiles', element: <ProfileManagementPage /> }, { path: 'api-configs', element: <Navigate to="/settings?group=api-configs" replace /> }, diff --git a/web/src/components/MyDevicesPanel.tsx b/web/src/components/MyDevicesPanel.tsx new file mode 100644 index 0000000..9b83e73 --- /dev/null +++ b/web/src/components/MyDevicesPanel.tsx @@ -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() +} diff --git a/web/src/components/TelegramBindPanel.tsx b/web/src/components/TelegramBindPanel.tsx new file mode 100644 index 0000000..4e6c7a7 --- /dev/null +++ b/web/src/components/TelegramBindPanel.tsx @@ -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> + ) +} diff --git a/web/src/components/layoutNavigation.ts b/web/src/components/layoutNavigation.ts index dc5b2f8..c0e7b6b 100644 --- a/web/src/components/layoutNavigation.ts +++ b/web/src/components/layoutNavigation.ts @@ -127,6 +127,10 @@ export function resolveHeaderBack(pathname: string): HeaderBackTarget | null { if (pathname.startsWith('/library/')) { return { to: '/libraries', label: '媒体库' } } + // 统计页从观看历史进入,返回链也回到那里,而不是一路跳回首页。 + if (pathname === '/history/stats') { + return { to: '/history', label: '观看历史' } + } if (pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') { return { to: '/', label: '首页' } } diff --git a/web/src/pages/ActiveUsersStrip.tsx b/web/src/pages/ActiveUsersStrip.tsx new file mode 100644 index 0000000..1e08c63 --- /dev/null +++ b/web/src/pages/ActiveUsersStrip.tsx @@ -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> + ) +} diff --git a/web/src/pages/AdminUserDevicesDialog.tsx b/web/src/pages/AdminUserDevicesDialog.tsx new file mode 100644 index 0000000..4eafa76 --- /dev/null +++ b/web/src/pages/AdminUserDevicesDialog.tsx @@ -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() +} diff --git a/web/src/pages/AdminUsersPanel.tsx b/web/src/pages/AdminUsersPanel.tsx index a07fc87..c5241e5 100644 --- a/web/src/pages/AdminUsersPanel.tsx +++ b/web/src/pages/AdminUsersPanel.tsx @@ -6,8 +6,10 @@ import type { User } from '../types' import { confirmAction } from '../components/confirmAction' import { requestPassword } from '../components/requestPassword' import { AdminUserLibrariesDialog } from '../components/AdminUserLibrariesDialog' +import { AdminUserDevicesDialog } from './AdminUserDevicesDialog' import { AdminUsersForm } from './AdminUsersForm' import { AdminUsersTable } from './AdminUsersTable' +import { ActiveUsersStrip } from './ActiveUsersStrip' const DEFAULT_MAX_USERS = 20 @@ -23,6 +25,7 @@ export function AdminUsersPanel() { const [editingUsername, setEditingUsername] = useState('') const [resettingPasswordID, setResettingPasswordID] = useState<string | null>(null) const [configuringLibrariesUser, setConfiguringLibrariesUser] = useState<User | null>(null) + const [managingDevicesUser, setManagingDevicesUser] = useState<User | null>(null) const refresh = async () => { const data = await adminAPI.listUsers() @@ -187,6 +190,8 @@ export function AdminUsersPanel() { onSubmit={handleCreate} /> + <ActiveUsersStrip users={users} /> + <AdminUsersTable users={users} editingID={editingID} @@ -198,6 +203,7 @@ export function AdminUsersPanel() { onStartEdit={startEdit} onResetPassword={resetPassword} onConfigureLibraries={(u) => setConfiguringLibrariesUser(u)} + onManageDevices={(u) => setManagingDevicesUser(u)} onToggleStatus={toggleStatus} onDeleteUser={deleteUser} /> @@ -211,6 +217,12 @@ export function AdminUsersPanel() { await refresh() }} /> + + <AdminUserDevicesDialog + user={managingDevicesUser} + isOpen={Boolean(managingDevicesUser)} + onClose={() => setManagingDevicesUser(null)} + /> </div> ) } diff --git a/web/src/pages/AdminUsersTable.tsx b/web/src/pages/AdminUsersTable.tsx index c7c91bd..1543a89 100644 --- a/web/src/pages/AdminUsersTable.tsx +++ b/web/src/pages/AdminUsersTable.tsx @@ -2,6 +2,7 @@ import { FolderLock, KeyRound, Loader2, + MonitorSmartphone, Pencil, ShieldCheck, Trash2, @@ -23,6 +24,7 @@ type AdminUsersTableProps = { onStartEdit: (user: User) => void onResetPassword: (user: User) => void onConfigureLibraries: (user: User) => void + onManageDevices: (user: User) => void onToggleStatus: (user: User) => void onDeleteUser: (user: User) => void } @@ -38,6 +40,7 @@ export function AdminUsersTable({ onStartEdit, onResetPassword, onConfigureLibraries, + onManageDevices, onToggleStatus, onDeleteUser, }: AdminUsersTableProps) { @@ -57,7 +60,7 @@ export function AdminUsersTable({ </thead> <tbody> {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"> {editingID === u.id ? ( <input @@ -147,6 +150,13 @@ export function AdminUsersTable({ > {resettingPasswordID === u.id ? <Loader2 size={12} className="animate-spin" /> : <KeyRound size={12} />} </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 className={ 'rounded-lg border px-2 py-1 text-xs disabled:cursor-not-allowed disabled:opacity-40 ' + diff --git a/web/src/pages/LibraryFilterBar.tsx b/web/src/pages/LibraryFilterBar.tsx new file mode 100644 index 0000000..d7b7abd --- /dev/null +++ b/web/src/pages/LibraryFilterBar.tsx @@ -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> + ) +} diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index 6bc4ce6..ec8c578 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -1,9 +1,17 @@ -import { useCallback, useMemo, useState, Fragment, type ReactNode } from 'react' -import { useLocation, useParams, useSearchParams } from 'react-router-dom' +import { useCallback, useEffect, useMemo, useState, Fragment, type ReactNode } from 'react' +import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { motion } from 'framer-motion' +import toast from 'react-hot-toast' import type { Media } from '../types' 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 { sortMediaList, @@ -15,6 +23,7 @@ import { LibraryPageDialogs } from './LibraryPageDialogs' import { PageBackButton } from '../components/PageBackButton' import { MediaFavouriteButton } from '../components/MediaFavouriteButton' import { LibraryPageHeader } from './LibraryPageHeader' +import { LibraryFilterBar } from './LibraryFilterBar' import { LibraryMediaSections } from './LibraryMediaSections' import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection' import { useLibraryData } from './useLibraryData' @@ -28,6 +37,7 @@ export function LibraryPage() { const { id = '' } = useParams() const [searchParams, setSearchParams] = useSearchParams() const location = useLocation() + const navigate = useNavigate() const role = useAuthStore((s) => s.user?.role) const canFavorite = usePermission('can_favorite') const { isFavourite, toggleFavourite } = useFavourites() @@ -57,6 +67,60 @@ export function LibraryPage() { const [selectedSeries, setSelectedSeries] = useState<SeriesCard | 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 { library, items, @@ -73,7 +137,7 @@ export function LibraryPage() { seriesCards, loadingAllText, reloadCurrentLibrary, - } = useLibraryData(id, selectedSeries, serverSortField, serverSortOrder) + } = useLibraryData(id, selectedSeries, serverSortField, serverSortOrder, libraryFilters) // 常规排序由服务端全局完成;只有 random 模式才在客户端洗牌。 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 isSeries={isSeries} items={displayedItems} diff --git a/web/src/pages/ProfilePage.tsx b/web/src/pages/ProfilePage.tsx index 5413114..8a29d0b 100644 --- a/web/src/pages/ProfilePage.tsx +++ b/web/src/pages/ProfilePage.tsx @@ -4,6 +4,8 @@ import { Check, Copy, EyeOff, KeyRound, Loader2, Save, Tv } from 'lucide-react' import { authAPI } from '../api/auth' import { profileAPI } from '../api/profile' +import { MyDevicesPanel } from '../components/MyDevicesPanel' +import { TelegramBindPanel } from '../components/TelegramBindPanel' import { requestPassword } from '../components/requestPassword' import { useAuthStore } from '../stores/auth' @@ -288,6 +290,10 @@ export function ProfilePage() { </div> ) : null} </section> + + <MyDevicesPanel /> + + <TelegramBindPanel /> </div> ) } diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 74ed738..6e24694 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -13,6 +13,7 @@ import { AdultSettingsPanel } from './AdultSettingsPanel' import { DatabaseSettingsPanel } from './DatabaseSettingsPanel' import { RecognitionWordsPanel } from './RecognitionWordsPanel' import { SettingRow } from './SettingsRow' +import { TelegramNotifyPanel } from './TelegramNotifyPanel' import { ALL_KEYS, GROUPS } from './settingsGroups' export function SettingsPage() { @@ -176,6 +177,7 @@ export function SettingsPage() { {group.key === 'adult' && <AdultSettingsPanel />} {group.key === 'general' && <FFToolsPanel onInstalled={() => refresh().catch(() => undefined)} />} {group.key === 'about' && <AboutSettingsPanel />} + {group.key === 'device-notify' && <TelegramNotifyPanel />} {group.key !== 'adult' && group.key !== 'library' && group.items.length > 0 && ( <form onSubmit={onSave} className="glass-panel space-y-4"> {group.description && <p className="text-xs text-sand-500">{group.description}</p>} diff --git a/web/src/pages/TelegramNotifyPanel.tsx b/web/src/pages/TelegramNotifyPanel.tsx new file mode 100644 index 0000000..36f210e --- /dev/null +++ b/web/src/pages/TelegramNotifyPanel.tsx @@ -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> + ) +} diff --git a/web/src/pages/WatchHistoryPage.tsx b/web/src/pages/WatchHistoryPage.tsx index 45979fc..27a712c 100644 --- a/web/src/pages/WatchHistoryPage.tsx +++ b/web/src/pages/WatchHistoryPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' 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 { historyAPI } from '../api/history' @@ -77,6 +77,13 @@ export function WatchHistoryPage() { <h1 className="font-display text-3xl font-bold text-ink-600">观看历史</h1> </div> <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 onClick={() => clearByStatus('incomplete')} disabled={busy !== '' || items.every((item) => item.completed)} diff --git a/web/src/pages/WatchStatsPage.tsx b/web/src/pages/WatchStatsPage.tsx new file mode 100644 index 0000000..7c8686c --- /dev/null +++ b/web/src/pages/WatchStatsPage.tsx @@ -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} /> +} diff --git a/web/src/pages/settingsGroupDeviceNotify.ts b/web/src/pages/settingsGroupDeviceNotify.ts new file mode 100644 index 0000000..ff70c42 --- /dev/null +++ b/web/src/pages/settingsGroupDeviceNotify.ts @@ -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: '统计「同时登录终端」时回看的天数窗口。', + }, + ], +} diff --git a/web/src/pages/settingsGroups.ts b/web/src/pages/settingsGroups.ts index 0370b2c..9a7a7e8 100644 --- a/web/src/pages/settingsGroups.ts +++ b/web/src/pages/settingsGroups.ts @@ -1,5 +1,6 @@ import { adultSettingsGroup } from './settingsGroupAccess' import { apiConfigsSettingsGroup } from './settingsGroupAPIConfigs' +import { deviceNotifySettingsGroup } from './settingsGroupDeviceNotify' import { generalSettingsGroup } from './settingsGroupGeneral' import { recognitionWordsSettingsGroup } from './settingsGroupRecognitionWords' import type { SettingGroup } from './settingsGroupTypes' @@ -22,6 +23,7 @@ export const aboutSettingsGroup: SettingGroup = { export const GROUPS: SettingGroup[] = [ generalSettingsGroup, + deviceNotifySettingsGroup, databaseSettingsGroup, apiConfigsSettingsGroup, recognitionWordsSettingsGroup, diff --git a/web/src/pages/useLibraryData.ts b/web/src/pages/useLibraryData.ts index f55d2da..5819dad 100644 --- a/web/src/pages/useLibraryData.ts +++ b/web/src/pages/useLibraryData.ts @@ -5,6 +5,11 @@ import { libraryAPI } from '../api/library' import type { Library, Media } from '../types' import { peekLibrary, resolveLibrary } from '../utils/libraryCache' 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 { MAX_RESTORE_PAGES, readListPosition, writeListPosition } from '../hooks/useListPositionMemory' @@ -13,6 +18,7 @@ export function useLibraryData( selectedSeries: SeriesCard | null, sortField: SortField, sortOrder: SortOrder, + filters: LibraryFilterParams = EMPTY_LIBRARY_FILTERS, ) { const [library, setLibrary] = useState<Library | null>(null) const [items, setItems] = useState<Media[]>([]) @@ -44,9 +50,10 @@ export function useLibraryData( const modeRef = useRef<'media' | 'series'>('media') 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), { sort: sortField, order: sortOrder, + filters, }) if (seq !== requestSeqRef.current) return false const pageItems = data.items ?? [] @@ -72,6 +80,7 @@ export function useLibraryData( const data = await libraryAPI.listMedia(libraryID, page, pageSizeFor(lib), { sort: sortField, order: sortOrder, + filters, }) if (seq !== requestSeqRef.current) return false const pageItems = data.items ?? [] @@ -93,7 +102,7 @@ export function useLibraryData( } return false } - }, [libraryID, positionKey, sortField, sortOrder]) + }, [libraryID, positionKey, sortField, sortOrder, filters]) const loadMore = useCallback(async (options?: { remember?: boolean }) => { if (moreInFlightRef.current || !hasMoreRef.current) return @@ -168,14 +177,22 @@ export function useLibraryData( const pageSize = pageSizeFor(lib) try { 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 const pageItems = data.items ?? [] setServerSeriesCards(pageItems) loadedCountRef.current = pageItems.length totalRef.current = data.total ?? pageItems.length } 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 const pageItems = data.items ?? [] setItems(pageItems) @@ -214,7 +231,7 @@ export function useLibraryData( cancelled = true requestSeqRef.current += 1 } - }, [appendNextPage, libraryID, positionKey, reloadTick, sortField, sortOrder]) + }, [appendNextPage, libraryID, positionKey, reloadTick, sortField, sortOrder, filters]) useEffect(() => { if (!libraryID || !isSeriesLibrary || !selectedSeries) { diff --git a/web/src/types/history.ts b/web/src/types/history.ts index e569d6a..15bb69a 100644 --- a/web/src/types/history.ts +++ b/web/src/types/history.ts @@ -11,10 +11,30 @@ export interface HistoryItem { 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 { total: number completed: number watched_ms: number watched_hours: number last_watched?: string + /** 正在看(未标记看完)的条目数。 */ + in_progress?: number + /** 近 30 天中有观看记录的日子,未观看的日期不返回。 */ + daily?: HistoryDailyStat[] + /** 按媒体库类型聚合的观看时长与条目数。 */ + by_library_type?: HistoryTypeStat[] + /** 最近看过的若干条,含 media 详情用于渲染卡片。 */ + recent?: Array<{ history: HistoryItem; media?: Media }> } diff --git a/web/src/utils/libraryFilters.ts b/web/src/utils/libraryFilters.ts new file mode 100644 index 0000000..2401bff --- /dev/null +++ b/web/src/utils/libraryFilters.ts @@ -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' +}