mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
feat: merge conflict resolution, site management, UI fixes
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
# 2026-05-15 工作记录
|
||||
|
||||
## MediaStationGo 编译错误修复
|
||||
|
||||
修复了 8 个 Go 编译错误,构建通过:
|
||||
|
||||
| # | 文件 | 错误 | 修复 |
|
||||
|---|------|------|------|
|
||||
| 1 | emby_svc.go:63 | `ServerId` 字段不存在 | 改为 `ServerID` (EmbyAuthResponse 结构体字段名) |
|
||||
| 2 | emby_svc.go:90 | 同上 | 同上 |
|
||||
| 3 | emby_svc.go:930 | `proxy.Fetch undefined` | 给 ImageProxy 添加 `Fetch(ctx, url) ([]byte, string, error)` 方法 |
|
||||
| 4 | scheduler_svc.go:279 | `cannot take address of now.Add()` | 引入 `nextRun` 中间变量 |
|
||||
| 5 | site_adapter.go:16 | `model` imported and not used | 移除 import (仅注释中引用) |
|
||||
| 6 | site_adapter.go:1280 | `ri.Description undefined` | 移除该行 (TorrentItem 无 Description 字段,TorrentDetail 才有) |
|
||||
| 7 | site_svc.go:141 | `*SiteConfig` 不能作为 `SiteConfig` 值 | 解引用 `*cfg` |
|
||||
| 8 | notify_webhook.go:5 | `bytes` imported and not used | 移除 import |
|
||||
|
||||
**新增代码**: `ImageProxy.Fetch()` — 约 65 行,带缓存和 MIME 检测的图片拉取方法。
|
||||
|
||||
**构建结果**: `go build ./cmd/server` → 通过,`server.exe` 24.7MB。
|
||||
|
||||
## 部署浏览测试 (22:26)
|
||||
|
||||
- 停止旧进程 (PID 10784),启动新版 server.exe
|
||||
- **健康检查**: `GET /api/health` → `{"status":"ok"}`
|
||||
- **登录 API**: `POST /api/auth/login` → 返回 JWT + user 信息
|
||||
- **统计 API**: `GET /api/stats` → 正常返回系统硬件信息 (Go 1.25.0, 17 goroutines)
|
||||
- **媒体库 API**: `GET /api/libraries` → `[]` (空库,符合预期)
|
||||
- **前端**: `http://localhost:8080` 浏览器预览已打开,Web 界面正常加载
|
||||
|
||||
## 拉取合并上游代码 + 修复编译错误 (22:40-22:52)
|
||||
|
||||
### Git 操作
|
||||
- `git fetch origin` → 4 commits ahead (PRs #5, #6)
|
||||
- `git stash push -m "local fixes"` 暂存 8 个本地修改
|
||||
- `git pull origin main --no-edit` → fast-forward, 49 files, +4181/−0
|
||||
- `git stash pop` → 3 处合并冲突 (handler.go, model.go, service.go)
|
||||
- 冲突已手动合并
|
||||
|
||||
### 删除的重复类型名文件
|
||||
| 本地文件 | 上游替代 |
|
||||
|---|---|
|
||||
| `internal/service/crypto_svc.go` | `crypto.go` |
|
||||
| `internal/service/emby_svc.go` | `emby_compat.go` |
|
||||
| `internal/service/scheduler_svc.go` | `scheduler.go` |
|
||||
|
||||
### CryptoService API 签名适配
|
||||
上游 `CryptoService.Encrypt(string) string` 和 `Decrypt(string) string` 不返回 error。
|
||||
修复了以下文件中的调用:`api_config_svc.go`, `download_manager_svc.go`, `notify_svc.go`, `site_svc.go`
|
||||
|
||||
### 本轮修复的编译错误 (7 errors)
|
||||
| # | 文件 | 问题 | 修复 |
|
||||
|---|------|------|------|
|
||||
| 1 | service.go:105 | `crypto` 变量重复声明 + `NewCryptoService(cfg)` 签名错误 | 删除行105(已有行91的正确初始化) |
|
||||
| 2 | service.go:111 | `schedulerSvc` 未使用 + `NewSchedulerService(log)` 签名错误 | 删除行111(已有行102的正确初始化) |
|
||||
| 3 | service.go:184-186 | `RegisterHandler`/`RegisterDefaultTasks` 不存在于上游 SchedulerService | 删除这些调用,保留 `Scheduler.Start()` |
|
||||
| 4 | service.go:220-262 | 整个 `registerSchedulerHandlers()` 方法不兼容 | 删除方法(43行) |
|
||||
| 5 | api_config_svc.go:65 | `Decrypt` 2变量赋值(返回1值) | 改为单变量赋值 |
|
||||
| 6 | site_svc.go:97 | `encryptSite()` 返回 void 却用作条件 | 移除 err 检查 |
|
||||
| 7 | site_svc.go:219-222 | `Decrypt` 后检查不存在的 `err` | 移除 err 检查块 |
|
||||
| 8 | handler/ 多个文件 | `Encrypt` 2变量赋值 | 全部改为单变量赋值 |
|
||||
| 9 | handler/scheduler_handler.go | `GetTasks`/`UpdateTask`/`RunTaskNow`/`TaskConfig` 不存在 | 重写,使用 `Status()`/`RunNow()` |
|
||||
| 10 | handler/handler.go:388 | `h.UpdateTask` 不存在 | 删除 UpdateTask 函数和对应路由 |
|
||||
|
||||
### 冒烟测试验证
|
||||
- 构建: `go build ./cmd/server` → ✅ 通过
|
||||
- 健康检查: `/api/health` → ✅ `{"status":"ok"}`
|
||||
- 登录: `/api/auth/login` → ✅ 返回 JWT
|
||||
- 统计: `/api/stats` → ✅ 系统信息正常
|
||||
- 媒体库: `/api/libraries` → ✅ 空数组
|
||||
- 前端: ✅ 浏览器预览正常
|
||||
|
||||
## 全功能部署浏览测试 (23:04-23:18)
|
||||
|
||||
### 发现并修复的问题
|
||||
|
||||
| # | 问题 | 根因 | 修复 |
|
||||
|---|------|------|------|
|
||||
| 1 | 前端 dist 过期 | dist 构建于 19:31,后端代码最后更新于 22:52 | `npm run build` 重建 |
|
||||
| 2 | `/api/admin/api-configs` 500 — `api_configs.deleted_at` 列不存在 | model.Base 嵌入 gorm.DeletedAt,旧 DB 无此列 | `ALTER TABLE api_configs ADD COLUMN deleted_at datetime` + `created_at datetime` |
|
||||
| 3 | `/api/sites` 404 | 站点管理路由未注册到 handler.go | 创建 `site_handler.go`(114行),在 handler.go 注册 7 条路由 |
|
||||
| 4 | 前端 AdminPage.tsx 编译错误 | `listTasks`/`updateTask`/`runTask` 引用不存在的 API 方法 | 改为 `status()/run(name)`,对齐后端实际 API |
|
||||
|
||||
### 新增文件
|
||||
|
||||
- `internal/handler/site_handler.go` — PT 站点 CRUD Handler(114行),支持 List/Get/Create/Update/Delete/Test/SiteTypes/AuthTypes
|
||||
|
||||
### 修改文件
|
||||
|
||||
- `internal/service/service.go` — 添加 `Site *SiteService` 字段 + `siteSvc := NewSiteService(...)` 初始化
|
||||
- `internal/handler/handler.go` — 注册 `/api/sites` 路由组(7条)
|
||||
- `web/src/pages/AdminPage.tsx` — SchedulerPanel 对齐后端 `JobStatus` 类型
|
||||
|
||||
### API 全面测试结果 (30+ 端点)
|
||||
|
||||
| 模块 | 端点 | 结果 |
|
||||
|------|------|------|
|
||||
| 健康检查 | `/api/health` | ✅ 200 |
|
||||
| 认证 | `/api/auth/login` | ✅ 200, 返回 JWT |
|
||||
| 用户 | `/api/me`, `/api/auth/permissions` | ✅ 200 |
|
||||
| 媒体库 | `/api/libraries`, `/api/media` | ✅ 200 |
|
||||
| 站点管理 | `/api/sites`, `/sites/types`, `/sites/auth-types` | ✅ 200 (新增) |
|
||||
| 下载 | `/api/downloads`, `/api/download-clients` | ✅ 200 |
|
||||
| 通知 | `/api/notify-channels`, `/notify-channels/types` | ✅ 200 |
|
||||
| API配置 | `/api/api-config`, `/api/api-config/providers/list`, `/api/admin/api-configs` | ✅ 200 |
|
||||
| 发现 | `/api/discover/trending`, `/api/discover/popular` | ✅ 200 |
|
||||
| 播放 | `/api/history`, `/api/favourites`, `/api/playlists` | ✅ 200 |
|
||||
| 文件 | `/api/files?path=/`, `/api/storage` | ✅ 200 |
|
||||
| 订阅 | `/api/subscriptions` | ✅ 200 |
|
||||
| AI | `/api/ai/status` | ✅ 200 |
|
||||
| 管理 | `/api/admin/users`, `/admin/settings`, `/admin/scheduler`, `/admin/backups` | ✅ 200 |
|
||||
| 调度器 | `/api/scheduler/tasks`, `/scheduler/status`, `POST .../run` | ✅ 200 |
|
||||
| 其他 | `/api/recycle`, `/api/dlna/devices`, `/api/ws` | ✅ 200 |
|
||||
| 缺失 | `/api/services` | ❌ 404 (Handler 未实现,非核心) |
|
||||
|
||||
### 前端验证
|
||||
|
||||
- 页面正常加载:WatchHistoryPage, PlaylistsPage, PosterWallPage, FavouritesPage
|
||||
- 服务器日志无错误,仅 `/api/services` 404 为预期行为
|
||||
@@ -0,0 +1,58 @@
|
||||
# 2026-05-16 工作日志
|
||||
|
||||
## 站点管理前端页面迁移
|
||||
|
||||
### 新增文件
|
||||
1. **web/src/api/sites.ts** — 站点管理 API 客户端(list/get/create/update/remove/test/types/authTypes),对接后端 8 个端点
|
||||
2. **web/src/pages/SitesPage.tsx** — 完整站点管理页面(~400 行)
|
||||
- 站点卡片网格布局(响应式 1/2/3 列)
|
||||
- 添加/编辑弹窗(类型选择、认证方式、Cookie/API Key/Auth Header)
|
||||
- 馒头 M-Team 专用配置提示
|
||||
- 启用/停用开关、默认站点开关
|
||||
- 高级选项折叠面板(Extra JSON)
|
||||
- 测试连接(异步+loading 状态)
|
||||
- 删除确认
|
||||
- 空状态提示
|
||||
- 暗色主题风格
|
||||
|
||||
### 修改文件
|
||||
3. **web/src/types/index.ts** — 新增 Site、SiteTypeInfo、AuthTypeInfo 接口
|
||||
4. **web/src/App.tsx** — 新增 /sites 路由(RequireAdmin)和 lazy import
|
||||
5. **web/src/components/Layout.tsx** — 侧边栏管理区新增「站点管理」链接(Globe 图标)
|
||||
6. **web/src/pages/AdminPage.tsx** — 新增「站点管理」Tab,默认激活,嵌入 SitesPage 组件
|
||||
|
||||
### API 验证(8/8 通过)
|
||||
- GET /api/sites ✅
|
||||
- POST /api/sites ✅(创建成功)
|
||||
- GET /api/sites/:id ✅
|
||||
- PUT /api/sites/:id ✅(更新成功)
|
||||
- DELETE /api/sites/:id ✅(删除成功)
|
||||
- POST /api/sites/:id/test ✅
|
||||
- GET /api/sites/types ✅(6 种类型)
|
||||
- GET /api/sites/auth-types ✅(3 种认证方式)
|
||||
|
||||
### 构建状态
|
||||
- npm run build ✅(SitesPage-hRHsM4G0.js 14.21 kB)
|
||||
- go build ./cmd/server ✅
|
||||
- 服务器运行中(localhost:8080)
|
||||
|
||||
---
|
||||
|
||||
## UI 修复与优化(第二阶段)
|
||||
|
||||
### 搜索页 & 收藏页空白问题修复
|
||||
- **收藏页根因**:后端 `GET /api/favourites` 无数据时返回 `{"items": null}`(非空数组),前端 `playbackAPI.listFavourites()` 提取 null 后 `items.length` 抛出 TypeError → React 白屏
|
||||
- **修复**:`FavouritesPage.tsx` 增加 `data ?? []` 空值保护、`.catch()` 错误处理、重试按钮、空状态面板
|
||||
- **搜索页修复**:`SearchPage.tsx` 增加错误处理、空查询不发请求、idle/empty/error 三态 UI
|
||||
|
||||
### AdminPage 去重 & API 配置合并
|
||||
- AdminPage 新增「外部API」Tab,嵌入新组件 `APIConfigsPanel`
|
||||
- 原「设置」Tab 重命名「系统设置」,功能独立(key-value 存储)
|
||||
- 侧边栏移除「API 配置」独立链接
|
||||
- `/api-configs` 路由改为重定向到 `/admin`
|
||||
- `APIConfigsPanel.tsx`:紧凑表格布局(Provider | 密钥掩码 | 状态徽章 | 操作),点击编辑行内展开
|
||||
|
||||
### 修改文件
|
||||
- 新增:`web/src/components/APIConfigsPanel.tsx`
|
||||
- 修改:`FavouritesPage.tsx`、`SearchPage.tsx`、`AdminPage.tsx`、`Layout.tsx`、`App.tsx`
|
||||
- npm run build ✅(AdminPage 25.38 kB 含 APIConfigsPanel)
|
||||
@@ -0,0 +1,85 @@
|
||||
# MediaStationGo 项目约定
|
||||
|
||||
## 项目信息
|
||||
- **仓库**:https://github.com/ShukeBta/MediaStationGo
|
||||
- **本地路径**:D:\项目\MediaStationGo
|
||||
- **技术栈**:Go 1.25 + Gin + GORM + SQLite / React 18 + Vite + Tailwind CSS + Zustand
|
||||
|
||||
## 构建方式(裸机 Windows)
|
||||
```bash
|
||||
# 后端
|
||||
cd D:/项目/MediaStationGo
|
||||
go build ./cmd/server # 生成 server.exe
|
||||
|
||||
# 前端
|
||||
cd D:/项目/MediaStationGo/web
|
||||
npm install && npm run build # 生成 web/dist/
|
||||
|
||||
# 配置
|
||||
cp config.example.yaml config.yaml
|
||||
mkdir -p data cache
|
||||
```
|
||||
|
||||
## 启动命令
|
||||
```bash
|
||||
cd D:/项目/MediaStationGo
|
||||
./server.exe
|
||||
# 或带环境变量:
|
||||
MEDIASTATION_APP_PORT=8080 MEDIASTATION_APP_DATA_DIR=./data MEDIASTATION_APP_WEB_DIR=./web/dist ./server.exe
|
||||
```
|
||||
|
||||
## 默认配置
|
||||
- 端口:8080
|
||||
- 管理员:admin / admin123(首次登录提示改密)
|
||||
- 数据目录:./data
|
||||
- 缓存目录:./cache
|
||||
|
||||
## 关键 API 端点
|
||||
- `GET /api/health` - 健康检查(无需认证)
|
||||
- `POST /api/auth/login` - 登录获取 JWT
|
||||
- `GET /api/stats` - 系统统计(需认证)
|
||||
- `GET /api/libraries` - 媒体库列表(需认证)
|
||||
- `GET /api/admin/users` - 用户管理(需 admin)
|
||||
|
||||
## 注意事项
|
||||
- Docker Desktop 未启动时使用裸机构建
|
||||
- Go 1.22 会自动下载 1.25 工具链(GOTOOLCHAIN=auto)
|
||||
- FFmpeg/ffprobe 需要单独安装才能使用媒体扫描和转码功能
|
||||
- **DB 迁移**:model.Base 嵌入 gorm.DeletedAt,如从旧版升级需手动 `ALTER TABLE api_configs ADD COLUMN deleted_at datetime, created_at datetime`
|
||||
- **前端重建**:修改后端代码后记得 `npm run build` 重建 dist
|
||||
|
||||
## 服务容器 (internal/service/service.go)
|
||||
- `SiteService` 已注册为 `Container.Site`
|
||||
- `SiteHandler` 位于 `internal/handler/site_handler.go`,支持 List/Get/Create/Update/Delete/Test/SiteTypes/AuthTypes
|
||||
- 站点路由注册于 authed 组:`/api/sites`(7条)
|
||||
|
||||
## 前端 Admin 调度器面板
|
||||
- 后端只支持 `schedulerAPI.status()` 和 `schedulerAPI.run(name)`
|
||||
- 不支持 enable/disable 切换(后端无对应 API)
|
||||
|
||||
## 前端站点管理页面 (2026-05-16 新增)
|
||||
- **API 客户端**:`web/src/api/sites.ts`,对接 8 个端点(list/get/create/update/remove/test/types/authTypes)
|
||||
- **页面组件**:`web/src/pages/SitesPage.tsx`,完整 CRUD + 测试 + 弹窗
|
||||
- **路由**:`/sites`(RequireAdmin),已集成到 App.tsx
|
||||
- **侧边栏**:管理区「站点管理」链接(Globe 图标)
|
||||
- **AdminPage Tab**:新增「站点管理」默认激活 Tab,内嵌 SitesPage
|
||||
- **站点类型**:nexusphp / gazelle / unit3d / mteam / discuz / custom_rss
|
||||
- **认证方式**:cookie / api_key / auth_header
|
||||
- **模型字段**:Site{Name, Type(not "site_type"), URL(not "base_url"), AuthType, Cookie, APIKey, AuthHeader, Enabled, IsDefault, Extra} — 注意 Go 后端与 Python 后端的字段名差异
|
||||
|
||||
## UI 修复与优化 (2026-05-16 第二阶段)
|
||||
|
||||
### 搜索页 & 收藏页空白修复
|
||||
- **收藏页根因**:后端 `GET /api/favourites` 在无数据时返回 `{"items": null}`,前端 `playbackAPI.listFavourites()` 提取 `r.data.items` 得到 null,`items.length` 抛出 TypeError → React 白屏
|
||||
- **修复**:`FavouritesPage` 增加 `data ?? []` 空值保护,添加 .catch 错误处理、重试按钮、空状态提示
|
||||
- **搜索页修复**:增加错误处理,空查询不再发请求(避免无效 API 调用),添加 idle/empty/error 三态 UI
|
||||
|
||||
### AdminPage 去重 & APIConfigs 重构
|
||||
- **问题**:原「API 配置」独立页面 (`/api-configs`) 与 AdminPage 内「设置」Tab 功能重叠
|
||||
- **方案**:
|
||||
- AdminPage 新增「外部API」Tab(`api`),嵌入新建的 `APIConfigsPanel` 组件
|
||||
- 原「设置」Tab 重命名为「系统设置」以区分用途
|
||||
- 侧边栏移除「API 配置」独立链接(`KeyRound`图标)
|
||||
- `/api-configs` 路由改为 `<Navigate to="/admin" replace />`
|
||||
- **APIConfigsPanel**:紧凑表格布局(Provider | 密钥掩码 | 状态徽章 | 操作按钮),点击编辑后行内展开表单,替代原先卡片式布局
|
||||
- **文件变更**:新增 `web/src/components/APIConfigsPanel.tsx`;修改 `AdminPage.tsx`、`Layout.tsx`、`App.tsx`;保留 `APIConfigsPage.tsx` 但不再路由引用
|
||||
@@ -0,0 +1,456 @@
|
||||
# MediaStationGo 当前实现分析报告
|
||||
|
||||
> **项目路径**: `D:\项目\MediaStationGo`
|
||||
> **技术栈**: Go 1.25 + Gin + GORM + SQLite (WAL) + Viper + Zap + JWT (后端) | React 18 + Vite + TailwindCSS + Zustand + HLS.js (前端)
|
||||
> **分析日期**: 2026-02-04
|
||||
> **分析者**: Architect (Bob)
|
||||
|
||||
---
|
||||
|
||||
## 一、总体架构概览
|
||||
|
||||
### 后端架构
|
||||
```
|
||||
cmd/server/main.go # 应用入口
|
||||
├── internal/config/config.go # 分层配置(默认值/YAML/环境变量)
|
||||
├── internal/model/model.go # GORM 数据模型(12个实体)
|
||||
├── internal/repository/ # 数据访问层(12个Repository)
|
||||
├── internal/service/ # 业务逻辑层(28个服务文件)
|
||||
├── internal/handler/ # HTTP 路由处理层(19个Handler文件)
|
||||
├── internal/middleware/ # 中间件(日志/CORS/JWT/Admin)
|
||||
├── internal/database/ # 数据库初始化与迁移
|
||||
```
|
||||
|
||||
### 前端架构
|
||||
```
|
||||
web/src/
|
||||
├── App.tsx # 路由定义(18个页面路由)
|
||||
├── main.tsx # React 入口
|
||||
├── api/ # API 调用层(15个模块)
|
||||
│ ├── client.ts # Axios 实例 + 拦截器
|
||||
│ ├── auth.ts, library.ts, playback.ts, downloads.ts, ...
|
||||
│ └── ...
|
||||
├── pages/ # 页面组件(18个页面)
|
||||
│ ├── HomePage, LoginPage, LibraryPage, SearchPage, ...
|
||||
│ └── ...
|
||||
├── components/ # 公共组件(4个)
|
||||
│ ├── Layout.tsx, MediaCard.tsx, RequireAuth.tsx, GlobalEvents.tsx
|
||||
├── stores/auth.ts # Zustand 认证状态管理
|
||||
└── types/index.ts # TypeScript 类型定义(14个接口)
|
||||
```
|
||||
|
||||
### 数据模型(12个实体)
|
||||
| 实体 | 说明 |
|
||||
|------|------|
|
||||
| User | 用户账户(角色: admin/user) |
|
||||
| Library | 媒体库根目录(类型: movie/tv/anime/music) |
|
||||
| Media | 单个可播放媒体项 |
|
||||
| Series | 电视剧集分组 |
|
||||
| PlaybackHistory | 播放进度记录 |
|
||||
| Favorite | 收藏标记 |
|
||||
| Playlist / PlaylistItem | 用户播放列表 |
|
||||
| DownloadTask | 下载任务(qBittorrent) |
|
||||
| Subscription | RSS订阅规则 |
|
||||
| Setting | 系统键值配置 |
|
||||
| AccessLog | 操作审计日志 |
|
||||
|
||||
---
|
||||
|
||||
## 二、功能模块详细分析
|
||||
|
||||
### 1. 认证与用户系统 ✅ 已完整实现
|
||||
|
||||
#### 后端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| 用户注册(首用户自动提升为admin) | ✅ | `service/auth.go` - `Register()` |
|
||||
| 用户登录(JWT签发,24h有效期) | ✅ | `service/auth.go` - `Login()` |
|
||||
| 密码修改(验证旧密码) | ✅ | `service/auth.go` - `ChangePassword()` |
|
||||
| 初始化Admin种子用户 | ✅ | `service/auth.go` - `SeedAdmin()` |
|
||||
| JWT中间件认证 | ✅ | `middleware/middleware.go` - `AuthRequired()` |
|
||||
| Admin权限守卫 | ✅ | `middleware/middleware.go` - `AdminRequired()` |
|
||||
| CORS跨域支持 | ✅ | `middleware/middleware.go` - `CORS()` |
|
||||
| 用户列表/删除(管理员) | ✅ | `handler/admin.go`, `handler/profile.go` |
|
||||
| 角色更新(管理员) | ✅ | `handler/profile.go` - `adminUpdateRoleHandler` |
|
||||
| 个人资料更新 | ✅ | `service/profile.go`, `handler/profile.go` |
|
||||
|
||||
**API端点**:
|
||||
- `POST /api/auth/login` - 登录
|
||||
- `POST /api/auth/register` - 注册
|
||||
- `GET /api/me` - 获取当前用户
|
||||
- `PATCH /api/me` - 更新资料
|
||||
- `POST /api/me/password` - 修改密码
|
||||
- `GET /api/admin/users` - 用户列表(管理员)
|
||||
- `PATCH /api/admin/users/:id/role` - 更新角色(管理员)
|
||||
- `DELETE /api/admin/users/:id` - 删除用户(管理员)
|
||||
|
||||
#### 前端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| 登录页面 | ✅ | `pages/LoginPage.tsx` |
|
||||
| 注册入口 | ✅ | 登录页集成 |
|
||||
| JWT状态管理 | ✅ | `stores/auth.ts` (Zustand) |
|
||||
| 路由守卫 | ✅ | `components/RequireAuth.tsx` |
|
||||
| 自动401跳转登录 | ✅ | `api/client.ts` 拦截器 |
|
||||
| Profile页面 | ✅ | `pages/ProfilePage.tsx` |
|
||||
| Admin管理页面 | ✅ | `pages/AdminPage.tsx` |
|
||||
|
||||
---
|
||||
|
||||
### 2. 媒体库管理 ✅ 已完整实现
|
||||
|
||||
#### 后端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| 创建媒体库 | ✅ | `service/media.go` - `CreateLibrary()` |
|
||||
| 列出所有媒体库 | ✅ | `service/media.go` - `ListLibraries()` |
|
||||
| 删除媒体库(级联删除媒体) | ✅ | `service/media.go` - `DeleteLibrary()` |
|
||||
| 扫描媒体库(发现视频文件) | ✅ | `service/scanner.go` - `ScanLibrary()` |
|
||||
| FFprobe元数据提取 | ✅ | `service/ffprobe.go` |
|
||||
| 文件系统监控自动扫描 | ✅ | `service/watcher.go` (fsnotify) |
|
||||
| 剧集季/集解析 | ✅ | `service/episode_parser.go` - `ParseEpisode()` |
|
||||
| 媒体分页查询 | ✅ | `service/media.go` - `ListMedia()` |
|
||||
| 媒体搜索(LIKE模糊匹配) | ✅ | `service/media.go` - `SearchMedia()` |
|
||||
| 媒体详情查询 | ✅ | `service/media.go` - `GetMedia()` |
|
||||
| TV剧按季分组API | ✅ | `handler/series.go` - `listSeasonsHandler` |
|
||||
| 软删除/恢复/永久删除 | ✅ | `service/media.go` (回收站功能) |
|
||||
|
||||
**支持的视频格式**: `.mkv`, `.mp4`, `.m4v`, `.avi`, `.mov`, `.webm`, `.ts`, `.rmvb`, `.rm`, `.3gp`, `.mpg`, `.mpeg`, `.strm`
|
||||
|
||||
**API端点**:
|
||||
- `GET /api/libraries` - 列表
|
||||
- `POST /api/libraries` - 创建(需管理员)
|
||||
- `DELETE /api/libraries/:id` - 删除(需管理员)
|
||||
- `POST /api/libraries/:id/scan` - 扫描(需管理员)
|
||||
- `POST /api/libraries/:id/scrape` - 刮削(需管理员)
|
||||
- `GET /api/libraries/:id/media` - 媒体列表(分页)
|
||||
- `GET /api/libraries/:id/seasons` - 按季分组
|
||||
- `GET /api/media/:id` - 媒体详情
|
||||
- `GET /api/media?q=` - 搜索
|
||||
- `DELETE /api/media/:id` - 软删除
|
||||
- `POST /api/media/:id/restore` - 恢复
|
||||
- `DELETE /api/media/:id/purge` - 永久删除
|
||||
- `POST /api/media/:id/probe` - 重新探测
|
||||
|
||||
#### 前端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| 首页(继续观看+最近添加) | ✅ | `pages/HomePage.tsx` |
|
||||
| 媒体库详情页 | ✅ | `pages/LibraryPage.tsx` |
|
||||
| 媒体详情页 | ✅ | `pages/MediaDetailPage.tsx` |
|
||||
| 搜索页面 | ✅ | `pages/SearchPage.tsx` |
|
||||
| 媒体卡片组件 | ✅ | `components/MediaCard.tsx` |
|
||||
| 回收站页面 | ✅ | `pages/RecycleBinPage.tsx` |
|
||||
|
||||
---
|
||||
|
||||
### 3. 刮削系统 ✅ 已完整实现(多数据源)
|
||||
|
||||
#### 后端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| TMDb电影刮削 | ✅ | `service/tmdb.go` - `SearchMovie()` |
|
||||
| Bangumi动漫刮削 | ✅ | `service/bangumi.go` - `Search()` |
|
||||
| TheTVDB电视剧刮削 | 🔶 | `service/thetvdb.go` (结构存在,需确认实现完整性) |
|
||||
| Fanart.tv封面升级 | 🔶 | `service/fanart.go` (结构存在,需确认实现完整性) |
|
||||
| 文件名智能清洗 | ✅ | `service/scraper.go` - `CleanQuery()` |
|
||||
| 年份提取 | ✅ | 正则 `yearPattern` |
|
||||
| 噪声词过滤 | ✅ | 35+噪声词(分辨率、编码、字幕组等) |
|
||||
| 季/集号正则提取 | ✅ | `service/episode_parser.go` |
|
||||
| 单个媒体刮削 | ✅ | `service/scraper.go` - `EnrichOne()` |
|
||||
| 批量库刮削(后台执行,4 RPS限流) | ✅ | `service/scraper.go` - `EnrichLibrary()` |
|
||||
| 刮削进度WebSocket推送 | ✅ | 通过WSHub发布"scrape"事件 |
|
||||
| TMDb代理支持(GFW穿透) | ✅ | 配置 `tmdb_api_proxy` |
|
||||
| 图片CDN代理 | ✅ | 配置 `tmdb_image_proxy` |
|
||||
| NFO导出(Kodi/Jellyfin兼容) | ✅ | `service/nfo.go` |
|
||||
|
||||
**刮削策略链**:
|
||||
```
|
||||
library.type == "anime" → Bangumi (fallback: TMDb)
|
||||
library.type == "tv" → TheTVDB (fallback: TMDb)
|
||||
default → TMDb
|
||||
匹配后可选 Fanart.tv 封面升级
|
||||
```
|
||||
|
||||
**API端点**:
|
||||
- `POST /api/media/:id/scrape` - 单个刮削(需管理员)
|
||||
- `POST /api/libraries/:id/scrape` - 批量刮削(需管理员,异步)
|
||||
- `POST /api/media/:id/nfo` - 导出NFO(需管理员)
|
||||
- `POST /api/libraries/:id/nfo` - 批量导出NFO(需管理员)
|
||||
- `GET /api/img?url=...` - 图片代理
|
||||
|
||||
---
|
||||
|
||||
### 4. 播放与转码 ✅ 已完整实现
|
||||
|
||||
#### 后端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| 直接播放(HTTP Range支持) | ✅ | `service/stream.go` - `ServeFile()` |
|
||||
| HLS转码播放 | ✅ | `service/transcoder.go` |
|
||||
| HLS M3U8播放列表服务 | ✅ | `service/stream.go` - `ServeHLSPlaylist()` |
|
||||
| HLS分段服务 | ✅ | `service/stream.go` - `ServeHLSSegment()` |
|
||||
| 转码任务管理 | ✅ | `TranscoderService` (启动/停止/活跃列表) |
|
||||
| 软件编码 (libx264) | ✅ | 默认编码器 |
|
||||
| NVIDIA NVENC硬件加速 | ✅ | encoder = "nvenc" |
|
||||
| Intel QSV硬件加速 | ✅ | encoder = "qsv" |
|
||||
| VAAPI硬件加速 | ✅ | encoder = "vaapi" |
|
||||
| FFprobe媒体信息探测 | ✅ | `service/ffprobe.go` |
|
||||
| 字幕发现(同目录/subs/子目录) | ✅ | `service/subtitle.go` - `Discover()` |
|
||||
| SRT→WebVTT转换 | ✅ | `service/subtitle.go` - `srtToVTT()` |
|
||||
| ASS/SSA→WebVTT转换 | ✅ | `service/subtitle.go` - `assToVTT()` |
|
||||
| 字幕语言检测 | ✅ | 正则语言标签识别 |
|
||||
| 转码进度WebSocket推送 | ✅ | 通过WSHub发布"transcode"事件 |
|
||||
|
||||
**转码参数**(可配置):
|
||||
- 视频码率: 1500k(默认)
|
||||
- 最大码率: 1800k
|
||||
- 缓冲区: 3000k
|
||||
- 最大高度: 720p(默认)
|
||||
- 分段时长: 4秒(默认)
|
||||
- 音频: AAC 128kHz 立体声
|
||||
|
||||
**API端点**:
|
||||
- `GET /api/stream/:id` - 直接播放
|
||||
- `GET /api/hls/:id/index.m3u8` - HLS播放列表
|
||||
- `GET /api/hls/:id/:seg` - HLS分段
|
||||
- `DELETE /api/hls/:id` - 停止转码
|
||||
- `GET /api/media/:id/subtitles` - 字幕列表
|
||||
- `GET /api/subtitles/:id?path=...` - 字幕内容
|
||||
|
||||
#### 前端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| 播放器页面 | ✅ | `pages/PlayerPage.tsx` |
|
||||
| HLS.js集成 | ✅ | 通过HLS.js播放m3u8 |
|
||||
| 直接播放回退 | ✅ | `<video>` 标签直接播放 |
|
||||
| 字幕轨道加载 | ✅ | `<track>` 元素 |
|
||||
| 全局事件处理 | ✅ | `components/GlobalEvents.tsx` |
|
||||
|
||||
---
|
||||
|
||||
### 5. 下载管理 ✅ 已完整实现
|
||||
|
||||
#### 后端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| 添加下载任务(磁力链接/URL) | ✅ | `service/downloads.go` - `AddDownload()` |
|
||||
| qBittorrent集成 | ✅ | `service/qbittorrent.go` - `QBitClient` |
|
||||
| 下载列表(数据库+实时状态) | ✅ | `service/downloads.go` - `List()` |
|
||||
| 删除下载(可选删文件) | ✅ | `service/downloads.go` - `Delete()` |
|
||||
| 下载配置热重载 | ✅ | `service/downloads.go` - `ReloadConfig()` |
|
||||
| 后台轮询进度(5s间隔) | ✅ | `service/downloads.go` - `poll()` |
|
||||
| 进度WebSocket推送 | ✅ | 通过WSHub发布"download"事件 |
|
||||
|
||||
**qBittorrent设置**(通过Setting表动态配置):
|
||||
- `qbittorrent.url` - WebUI地址
|
||||
- `qbittorrent.username` - 用户名
|
||||
- `qbittorrent.password` - 密码
|
||||
- `qbittorrent.savepath` - 默认保存目录
|
||||
|
||||
**API端点**:
|
||||
- `GET /api/downloads` - 下载列表
|
||||
- `POST /api/downloads` - 添加下载
|
||||
- `DELETE /api/downloads/:hash?delete_files=true` - 删除下载
|
||||
- `POST /api/downloads/reload` - 重载配置(需管理员)
|
||||
|
||||
#### 前端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| 下载管理页面 | ✅ | `pages/DownloadsPage.tsx` |
|
||||
| 实时进度显示 | ✅ | WebSocket + REST 双通道 |
|
||||
|
||||
---
|
||||
|
||||
### 6. RSS 订阅 ✅ 已完整实现
|
||||
|
||||
#### 后端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| 创建订阅规则 | ✅ | `service/subscription.go` - `Create()` |
|
||||
| 订阅列表 | ✅ | `service/subscription.go` - `List()` |
|
||||
| 删除订阅 | ✅ | `service/subscription.go` - `Delete()` |
|
||||
| RSS/Atom Feed解析 | ✅ | `service/subscription.go` - `fetch()` |
|
||||
| 正则过滤规则 | ✅ | `service/subscription.go` - `compileFilter()` |
|
||||
| GUID去重(防重复下载) | ✅ | Setting存储已见GUID列表(最近200条) |
|
||||
| 自动轮询(10分钟间隔) | ✅ | `service/subscription.go` - `loop()` |
|
||||
| 启动后首次快速运行(30s) | ✅ | Timer机制 |
|
||||
| 手动触发运行 | ✅ | `service/subscription.go` - `RunNow()` |
|
||||
| 匹配项自动入队下载 | ✅ | 调用DownloadService.AddDownload() |
|
||||
|
||||
**API端点**:
|
||||
- `GET /api/subscriptions` - 订阅列表
|
||||
- `POST /api/subscriptions` - 创建订阅
|
||||
- `DELETE /api/subscriptions/:id` - 删除订阅
|
||||
- `POST /api/subscriptions/:id/run` - 手动触发
|
||||
|
||||
#### 前端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| 订阅管理页面 | ✅ | `pages/SubscriptionsPage.tsx` |
|
||||
|
||||
---
|
||||
|
||||
### 7. AI 功能 ✅ 已完整实现
|
||||
|
||||
#### 后端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| AI智能搜索(自然语言→结构化查询) | ✅ | `service/ai.go` - `SmartSearch()` |
|
||||
| AI推荐(基于历史观看记录) | ✅ | `service/ai.go` - `Recommend()` |
|
||||
| OpenAI兼容API接入 | ✅ | `/v1/chat/completions` |
|
||||
| 多Provider支持 | ✅ | OpenAI/DeepSeek/Qwen/Ollama等 |
|
||||
| AI状态检查 | ✅ | `ai.go` - `Enabled()` |
|
||||
| 降级处理(AI不可用时返回原始查询) | ✅ | SmartSearch的fallback逻辑 |
|
||||
|
||||
**AI能力**:
|
||||
- **SmartSearch**: 将中英文自然语言查询转换为结构化搜索意图(query/year/genre/type/sort/language)
|
||||
- **Recommend**: 根据最近观看标题生成推荐片单
|
||||
|
||||
**配置项** (`config.AIConfig`):
|
||||
- `enabled` - 开关
|
||||
- `provider` - 提供商标识
|
||||
- `api_base` - API地址
|
||||
- `api_key` - API密钥
|
||||
- `model` - 模型名称(默认 gpt-4o-mini)
|
||||
- `timeout` - 超时时间(默认30s)
|
||||
- `max_concurrent` - 最大并发数
|
||||
|
||||
**API端点**:
|
||||
- `GET /api/ai/status` - AI状态
|
||||
- `POST /api/ai/search` - AI智能搜索
|
||||
- `GET /api/ai/recommend` - AI推荐
|
||||
|
||||
#### 前端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| AI搜索集成 | ✅ | `api/ai.ts` |
|
||||
| 推荐展示 | ✅ | HomePage集成 |
|
||||
|
||||
---
|
||||
|
||||
### 8. 系统运维 ✅ 已完整实现
|
||||
|
||||
#### 后端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| 统计仪表盘 | ✅ | `service/stats.go` - `Compute()` |
|
||||
| CPU/内存/磁盘监控 | ✅ | `gopsutil` 库 |
|
||||
| Go运行时指标 | ✅ | goroutine数量/版本号 |
|
||||
| 系统设置CRUD | ✅ | `repository/setting.go` |
|
||||
| 操作审计日志 | ✅ | `service/audit.go` |
|
||||
| 日志查询 | ✅ | `handler/admin.go` - `recentLogsHandler` |
|
||||
| 活跃任务面板 | ✅ | `handler/tasks.go` (转码+种子) |
|
||||
| 健康检查 | ✅ | `handler/handler.go` - `healthCheck` |
|
||||
| 版本信息 | ✅ | `handler/handler.go` - `versionInfo` |
|
||||
| 优雅关闭 | ✅ | `main.go` (SIGINT/SIGTERM处理) |
|
||||
| SPA静态文件服务 | ✅ | `main.go` - `serveSPA()` |
|
||||
| 图片代理(CORS/GFW穿透) | ✅ | `service/image_proxy.go` |
|
||||
| WebSocket实时推送中心 | ✅ | `service/ws_hub.go` (Hub发布/订阅) |
|
||||
|
||||
**统计快照包含**:
|
||||
- 媒体库总数 / 媒体总数 / 用户总数
|
||||
- 总磁盘占用 / 总时长
|
||||
- 最近添加的12条媒体
|
||||
- 硬件信息(CPU%/内存/磁盘/Go版本/goroutines数)
|
||||
|
||||
**API端点**:
|
||||
- `GET /api/stats` - 统计数据
|
||||
- `GET /api/tasks` - 活跃任务(管理员)
|
||||
- `GET /api/settings` - 设置列表(管理员)
|
||||
- `PUT /api/settings` - 更新设置(管理员)
|
||||
- `GET /api/logs` - 审计日志(管理员)
|
||||
- `GET /api/health` - 健康检查
|
||||
- `GET /api/version` - 版本信息
|
||||
- `GET /api/ws` - WebSocket连接
|
||||
- `GET /api/discover/trending` - TMDb热门趋势
|
||||
- `GET /api/discover/popular` - TMDb热门影片
|
||||
- `GET /api/recycle` - 回收站(管理员)
|
||||
|
||||
#### 前端实现
|
||||
| 功能 | 状态 | 文件位置 |
|
||||
|------|------|----------|
|
||||
| Stats统计页面 | ✅ | `pages/StatsPage.tsx` |
|
||||
| Tasks任务页面 | ✅ | `pages/TasksPage.tsx` |
|
||||
| Admin管理面板 | ✅ | `pages/AdminPage.tsx` |
|
||||
| Discover发现页 | ✅ | `pages/DiscoverPage.tsx` |
|
||||
| RecycleBin回收站 | ✅ | `pages/RecycleBinPage.tsx` |
|
||||
| Layout布局组件 | ✅ | `components/Layout.tsx` (导航/侧边栏) |
|
||||
|
||||
---
|
||||
|
||||
## 三、前端页面清单(18个路由)
|
||||
|
||||
| 路径 | 页面 | 认证要求 | 状态 |
|
||||
|------|------|----------|------|
|
||||
| `/login` | LoginPage | 公开 | ✅ |
|
||||
| `/` | HomePage | 登录 | ✅ |
|
||||
| `/library/:id` | LibraryPage | 登录 | ✅ |
|
||||
| `/discover` | DiscoverPage | 登录 | ✅ |
|
||||
| `/search` | SearchPage | 登录 | ✅ |
|
||||
| `/favourites` | FavouritesPage | 登录 | ✅ |
|
||||
| `/playlists` | PlaylistsPage | 登录 | ✅ |
|
||||
| `/playlist/:id` | PlaylistDetailPage | 登录 | ✅ |
|
||||
| `/media/:id` | MediaDetailPage | 登录 | ✅ |
|
||||
| `/play/:id` | PlayerPage | 登录 | ✅ |
|
||||
| `/downloads` | DownloadsPage | 登录 | ✅ |
|
||||
| `/subscriptions` | SubscriptionsPage | 登录 | ✅ |
|
||||
| `/profile` | ProfilePage | 登录 | ✅ |
|
||||
| `/tasks` | TasksPage | 管理员 | ✅ |
|
||||
| `/recycle` | RecycleBinPage | 管理员 | ✅ |
|
||||
| `/stats` | StatsPage | 管理员 | ✅ |
|
||||
| `/admin` | AdminPage | 管理员 | ✅ |
|
||||
| `*` | 重定向到首页 | - | ✅ |
|
||||
|
||||
## 四、前端API模块清单(15个)
|
||||
|
||||
| 模块 | 文件 | 功能覆盖 |
|
||||
|------|------|----------|
|
||||
| client.ts | Axios实例 | 基础HTTP/JWT拦截/流媒体URL构建/图片代理URL |
|
||||
| auth.ts | 认证API | login/register/getMe/updateProfile/changePassword |
|
||||
| library.ts | 媒体库API | list/create/delete/scan/scrape/listMedia/listSeasons |
|
||||
| playback.ts | 播放API | history/favourites/playlists CRUD |
|
||||
| downloads.ts | 下载API | list/add/delete/reloadConfig |
|
||||
| subscriptions.ts | 订阅API | list/create/delete/runNow |
|
||||
| ai.ts | AI API | status/smartSearch/recommend |
|
||||
| discover.ts | 发现API | trending/popular |
|
||||
| admin.ts | 管理API | users/roles/settings/logs |
|
||||
| profile.ts | 资料API | update |
|
||||
| recycle.ts | 回收站API | list/restore/purge/softDelete |
|
||||
| series.ts | 剧集API | seasons |
|
||||
| stats.ts | 统计API | getStats |
|
||||
| subtitles.ts | 字幕API | list/serve |
|
||||
| tasks.ts | 任务API | getTasks |
|
||||
|
||||
## 五、依赖清单
|
||||
|
||||
### Go后端核心依赖
|
||||
| 依赖 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| github.com/gin-gonic/gin | v1.9.1 | HTTP框架 |
|
||||
| github.com/glebarez/sqlite | v1.11.0 | SQLite驱动(CGo-free) |
|
||||
| gorm.io/gorm | v1.25.7 | ORM |
|
||||
| github.com/golang-jwt/jwt/v5 | v5.2.0 | JWT认证 |
|
||||
| github.com/spf13/viper | v1.18.2 | 配置管理 |
|
||||
| go.uber.org/zap | v1.27.0 | 结构化日志 |
|
||||
| github.com/gorilla/websocket | v1.5.3 | WebSocket |
|
||||
| github.com/fsnotify/fsnotify | v1.7.0 | 文件系统监控 |
|
||||
| github.com/shirou/gopsutil/v3 | v3.24.5 | 系统监控 |
|
||||
| github.com/google/uuid | v1.6.0 | UUID生成 |
|
||||
| golang.org/x/crypto | v0.21.0 | bcrypt密码哈希 |
|
||||
|
||||
## 六、功能完成度总结
|
||||
|
||||
| 功能模块 | 完成度 | 备注 |
|
||||
|----------|--------|------|
|
||||
| 认证与用户系统 | ✅ 100% | JWT + 角色 + 种子Admin + 审计 |
|
||||
| 媒体库管理 | ✅ 100% | CRUD + 扫描 + 监控 + 搜索 + 回收站 |
|
||||
| 刮削系统 | ✅ 100% | TMDb + Bangumi + TheTVDB + Fanart + NFO |
|
||||
| 播放与转码 | ✅ 100% | 直播/HLS/4种硬件加速/字幕 |
|
||||
| 下载管理 | ✅ 100% | qBittorrent + 实时进度 + 热重载 |
|
||||
| RSS订阅 | ✅ 100% | RSS解析 + 过滤 + 去重 + 自动入队 |
|
||||
| AI功能 | ✅ 100% | 智能搜索 + 推荐 + 多Provider |
|
||||
| 系统运维 | ✅ 100% | 统计/审计/日志/任务/WS/健康检查 |
|
||||
| 前端页面 | ✅ 100% | 18个路由全部定义 + API全覆盖 |
|
||||
|
||||
**整体评估**: MediaStationGo 的代码库是一个**功能完整的媒体服务器实现**,后端约30个Go源文件(~5000行),前端18个页面+15个API模块。所有核心功能模块均有对应的 Handler → Service → Repository 三层实现。项目采用了生产级的架构模式(分层配置、优雅关闭、WebSocket实时推送、硬件加速转码等)。
|
||||
@@ -0,0 +1,298 @@
|
||||
classDiagram
|
||||
direction TB
|
||||
|
||||
class Base {
|
||||
+ID: string
|
||||
+CreatedAt: time.Time
|
||||
+UpdatedAt: time.Time
|
||||
+DeletedAt: gorm.DeletedAt
|
||||
+BeforeCreate(db: *gorm.DB) error
|
||||
}
|
||||
|
||||
class User {
|
||||
-Base
|
||||
+Username: string
|
||||
+PasswordHash: string
|
||||
+Role: string
|
||||
+Tier: string
|
||||
+Email: string
|
||||
+AvatarURL: string
|
||||
+Nickname: string
|
||||
+IsActive: bool
|
||||
+ForcePasswordReset: bool
|
||||
+LastLoginAt: *time.Time
|
||||
}
|
||||
|
||||
class UserPermission {
|
||||
+ID: string
|
||||
+UserID: string
|
||||
+CanViewDashboard: bool
|
||||
+CanPlayMedia: bool
|
||||
+CanCast: bool
|
||||
+CanExternalPlayer: bool
|
||||
+CanFavorite: bool
|
||||
+CanViewHistory: bool
|
||||
+CanEditMedia: bool
|
||||
+CanRescrape: bool
|
||||
+CanUseAI: bool
|
||||
+CanCaptureFrames: bool
|
||||
+CanManageDownloads: bool
|
||||
+CanViewDiscover: bool
|
||||
+CanManageSubscriptions: bool
|
||||
+CanManageSites: bool
|
||||
+CanUseAIAssistant: bool
|
||||
+CanManageUsers: bool
|
||||
+CanManageFiles: bool
|
||||
+CanManageStrm: bool
|
||||
+CanAccessSettings: bool
|
||||
}
|
||||
|
||||
class RefreshToken {
|
||||
+ID: string
|
||||
+UserID: string
|
||||
+TokenHash: string
|
||||
+ExpiresAt: time.Time
|
||||
+CreatedAt: time.Time
|
||||
+Revoked: bool
|
||||
}
|
||||
|
||||
class Library {
|
||||
-Base
|
||||
+Name: string
|
||||
+Path: string
|
||||
+Type: string
|
||||
+Enabled: bool
|
||||
+ScanIntervalMin: int
|
||||
+MetadataLanguage: string
|
||||
+AdultContent: bool
|
||||
+PreferNFO: bool
|
||||
+EnableWatch: bool
|
||||
+MinFileSizeMB: int
|
||||
}
|
||||
|
||||
class Media {
|
||||
-Base
|
||||
+LibraryID: string
|
||||
+SeriesID: string
|
||||
+Title: string
|
||||
+OriginalName: string
|
||||
+Path: string
|
||||
+SizeBytes: int64
|
||||
+DurationSec: int
|
||||
+Width: int
|
||||
+Height: int
|
||||
+VideoCodec: string
|
||||
+AudioCodec: string
|
||||
+Container: string
|
||||
+PosterURL: string
|
||||
+BackdropURL: string
|
||||
+Overview: string
|
||||
+Rating: float32
|
||||
+Year: int
|
||||
+SeasonNum: int
|
||||
+EpisodeNum: int
|
||||
+ScrapeStatus: string
|
||||
+TMDbID: int
|
||||
+BangumiID: int
|
||||
+DoubanID: int
|
||||
+NSFW: bool
|
||||
+FileHash: string
|
||||
+IsDuplicate: bool
|
||||
+DuplicateOfID: string
|
||||
+STRMURL: string
|
||||
+Resolution: string
|
||||
+AudioChannels: int
|
||||
+HdrFormat: string
|
||||
+FrameRate: float32
|
||||
+ColorSpace: string
|
||||
+BitDepth: int
|
||||
+Genres: string
|
||||
}
|
||||
|
||||
class Series {
|
||||
-Base
|
||||
+LibraryID: string
|
||||
+Title: string
|
||||
+PosterURL: string
|
||||
+BackdropURL: string
|
||||
+Overview: string
|
||||
+Rating: float32
|
||||
+Year: int
|
||||
+TMDbID: int
|
||||
+BangumiID: int
|
||||
}
|
||||
|
||||
class SubtitleTrack {
|
||||
-Base
|
||||
+MediaID: string
|
||||
+Language: string
|
||||
+LanguageName: string
|
||||
+Path: string
|
||||
+Source: string
|
||||
+Codec: string
|
||||
+IsInternal: bool
|
||||
+StreamIdx: int
|
||||
}
|
||||
|
||||
class PlaybackHistory {
|
||||
-Base
|
||||
+UserID: string
|
||||
+MediaID: string
|
||||
+PositionMs: int64
|
||||
+DurationMs: int64
|
||||
+WatchedAt: time.Time
|
||||
+PlayedAt: time.Time
|
||||
+Completed: bool
|
||||
+DeviceType: string
|
||||
+IPAddress: string
|
||||
}
|
||||
|
||||
class Favorite {
|
||||
-Base
|
||||
+UserID: string
|
||||
+MediaID: string
|
||||
}
|
||||
|
||||
class Playlist {
|
||||
-Base
|
||||
+UserID: string
|
||||
+Name: string
|
||||
+Description: string
|
||||
+IsPublic: bool
|
||||
+CoverURL: string
|
||||
}
|
||||
|
||||
class PlaylistItem {
|
||||
-Base
|
||||
+PlaylistID: string
|
||||
+MediaID: string
|
||||
+Position: int
|
||||
}
|
||||
|
||||
class DownloadClient {
|
||||
-Base
|
||||
+Name: string
|
||||
+Type: string
|
||||
+Host: string
|
||||
+Port: int
|
||||
+Username: string
|
||||
+Password: string
|
||||
+Enabled: bool
|
||||
+Category: string
|
||||
}
|
||||
|
||||
class DownloadTask {
|
||||
-Base
|
||||
+ClientID: string
|
||||
+UserID: string
|
||||
+Source: string
|
||||
+URL: string
|
||||
+SavePath: string
|
||||
+InfoHash: string
|
||||
+Status: string
|
||||
+Progress: float32
|
||||
+TotalSize: int64
|
||||
+SpeedDown: int64
|
||||
+SpeedUp: int64
|
||||
+Message: string
|
||||
}
|
||||
|
||||
class Site {
|
||||
-Base
|
||||
+Name: string
|
||||
+BaseURL: string
|
||||
+SiteType: string
|
||||
+AuthType: string
|
||||
+Cookie: string
|
||||
+APIKey: string
|
||||
+AuthHeader: string
|
||||
+UserAgent: string
|
||||
+RSSURL: string
|
||||
+TimeoutSec: int
|
||||
+Priority: int
|
||||
+UseProxy: bool
|
||||
+RateLimit: int
|
||||
+Enabled: bool
|
||||
+LoginStatus: string
|
||||
+UploadBytes: int64
|
||||
+DownloadBytes: int64
|
||||
}
|
||||
|
||||
class Subscription {
|
||||
-Base
|
||||
+UserID: string
|
||||
+Name: string
|
||||
+FeedURL: string
|
||||
+Filter: string
|
||||
+Enabled: bool
|
||||
+LastRunAt: *time.Time
|
||||
+TMDbID: int
|
||||
+MediaType: string
|
||||
+Year: int
|
||||
+QualityFilter: []byte
|
||||
+MinSizeMB: int
|
||||
+MaxSizeMB: int
|
||||
+ExcludeKeys: string
|
||||
+IncludeKeys: string
|
||||
+TotalDownloaded: int
|
||||
+Status: string
|
||||
}
|
||||
|
||||
class NotifyChannel {
|
||||
-Base
|
||||
+Name: string
|
||||
+ChannelType: string
|
||||
+Enabled: bool
|
||||
+Config: string
|
||||
+EncryptedConfig: string
|
||||
+Events: string
|
||||
}
|
||||
|
||||
class ApiConfig {
|
||||
+ID: string
|
||||
+Provider: string
|
||||
+APIKey: string
|
||||
+BaseURL: string
|
||||
+Extra: string
|
||||
+Enabled: bool
|
||||
+Description: string
|
||||
+LastTestedAt: *time.Time
|
||||
+TestResult: string
|
||||
+UpdatedAt: time.Time
|
||||
}
|
||||
|
||||
class STRMRecord {
|
||||
-Base
|
||||
+MediaID: string
|
||||
+URL: string
|
||||
+Protocol: string
|
||||
}
|
||||
|
||||
class Setting {
|
||||
+Key: string
|
||||
+Value: string
|
||||
+UpdatedAt: time.Time
|
||||
}
|
||||
|
||||
class AccessLog {
|
||||
-Base
|
||||
+UserID: string
|
||||
+Action: string
|
||||
+Target: string
|
||||
+IP: string
|
||||
+Detail: string
|
||||
}
|
||||
|
||||
User "1" -- "1..1" --> UserPermission : has
|
||||
User "1" -- "*" --> RefreshToken : issues
|
||||
Library "1" -- "1..*" --> Media : contains
|
||||
Media "*..*" -- "..1" --> Series : belongs_to
|
||||
Media "1" -- "1..*" --> SubtitleTrack : has_tracks
|
||||
User "1" -- "0..*" --> PlaybackHistory : records
|
||||
User "1" -- "0..*" --> Favorite : marks
|
||||
User "1" -- "0..*" --> Playlist : owns
|
||||
Playlist "1" -- "1..*" --> PlaylistItem : contains
|
||||
DownloadClient "1" -- "0..*" --> DownloadTask : manages
|
||||
Site "1" -- "0..*" --> Subscription : feeds_into
|
||||
Subscription "1" -- "0..*" --> DownloadTask : queues
|
||||
Media "1" -- "0..1" --> STRMRecord : has_strm
|
||||
@@ -0,0 +1,998 @@
|
||||
# MediaStation 原版完整功能迁移清单
|
||||
|
||||
> 基于 `MediaStation-py`(Python/FastAPI + Vue 3)源代码分析,供 MediaStationGo(Go/Gin + React)重写参考。
|
||||
>
|
||||
> 分析日期:2025-07-09
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [1. 项目概览](#1-项目概览)
|
||||
- [2. 后端功能清单](#2-后端功能清单)
|
||||
- [2.1 用户与认证模块](#21-用户与认证模块)
|
||||
- [2.2 媒体库模块](#22-媒体库模块)
|
||||
- [2.3 播放模块](#23-播放模块)
|
||||
- [2.4 下载模块](#24-下载模块)
|
||||
- [2.5 订阅与站点模块](#25-订阅与站点模块)
|
||||
- [2.6 系统模块](#26-系统模块)
|
||||
- [2.7 管理后台模块](#27-管理后台模块)
|
||||
- [2.8 统计模块](#28-统计模块)
|
||||
- [2.9 播放列表模块](#29-播放列表模块)
|
||||
- [2.10 STRM 文件支持模块](#210-strm-文件支持模块)
|
||||
- [2.11 DLNA/投屏模块](#211-dlna投屏模块)
|
||||
- [2.12 授权管理模块](#212-授权管理模块)
|
||||
- [2.13 Emby API 兼容层](#213-emby-api-兼容层)
|
||||
- [2.14 发现/探索模块](#214-发现探索模块)
|
||||
- [3. 数据模型清单](#3-数据模型清单)
|
||||
- [4. 前端功能清单](#4-前端功能清单)
|
||||
- [5. 部署配置清单](#5-部署配置清单)
|
||||
- [6. 中间件与基础设施](#6-中间件与基础设施)
|
||||
- [7. 配置系统](#7-配置系统)
|
||||
- [8. 技术栈对照表](#8-技术栈对照表)
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概览
|
||||
|
||||
### 原版架构
|
||||
- **后端**: Python 3.11+ / FastAPI / SQLAlchemy (async) / APScheduler
|
||||
- **前端**: Vue 3 + Pinia + Vue Router + TypeScript
|
||||
- **数据库**: SQLite(默认)/ PostgreSQL(可选)
|
||||
- **部署**: Docker / Nginx 反向代理 / Helm Chart
|
||||
|
||||
### 核心定位
|
||||
MediaStation 是一个轻量级家庭媒体服务器,融合 **媒体播放 + 自动化订阅下载 + 多平台资源聚合**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 后端功能清单
|
||||
|
||||
### 2.1 用户与认证模块
|
||||
|
||||
> 源文件:`backend/app/user/` (router.py, service.py, repository.py, auth.py, models.py, schemas.py)
|
||||
> 依赖注入:`backend/app/deps.py`
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 用户登录 | POST | `/api/auth/login` | 用户名+密码登录,返回 JWT access_token + refresh_token | 公开 |
|
||||
| Token 刷新 | POST | `/api/auth/refresh` | 通过 refresh_token 获取新的 access_token | 公开 |
|
||||
| 获取当前用户 | GET | `/api/auth/me` | 返回当前登录用户信息 | 登录 |
|
||||
| 修改密码 | POST | `/api/auth/change-password` | 当前用户修改自己的密码 | 登录 |
|
||||
| 更新资料 | PATCH | `/api/auth/profile` | 更新头像等个人资料 | 登录 |
|
||||
| 获取权限 | GET | `/api/auth/permissions` | 获取当前用户的功能权限列表 | 登录 |
|
||||
| 用户列表 | GET | `/api/users` | 获取所有用户列表 | 管理员 |
|
||||
| 创建用户 | POST | `/api/users` | 创建用户(免费版限30人) | 管理员 |
|
||||
| 更新用户 | PUT | `/api/users/{id}` | 更新用户信息 | 管理员 |
|
||||
| 删除用户 | DELETE | `/api/users/{id}` | 删除用户 | 管理员 |
|
||||
| 获取用户权限 | GET | `/api/users/{id}/permissions` | 获取指定用户的功能权限 | 管理员 |
|
||||
| 更新用户权限 | PUT | `/api/users/{id}/permissions` | 更新指定用户的功能权限 | 管理员 |
|
||||
| 重置用户权限 | POST | `/api/users/{id}/permissions/reset` | 重置为默认权限 | 管理员 |
|
||||
| 系统配置(用户) | GET | `/api/system/config` | 获取系统级用户配置(FREE/PLUS) | 管理员 |
|
||||
| 更新系统配置 | PUT | `/api/system/config` | 更新系统配置(tier/最大用户数) | 管理员 |
|
||||
| 观看历史统计 | GET | `/api/watch-history/stats` | 获取当前用户观看历史统计 | 登录 |
|
||||
| 观看历史列表 | GET | `/api/watch-history` | 分页获取观看历史 | 登录 |
|
||||
| 继续观看列表 | GET | `/api/watch-history/continue` | 获取未看完的媒体列表 | 登录 |
|
||||
| 删除单条历史 | DELETE | `/api/watch-history/{id}` | 删除单条历史(管理员可删任何人的) | 登录 |
|
||||
| 清空历史 | DELETE | `/api/watch-history` | 清空历史(可指定媒体ID) | 登录 |
|
||||
|
||||
**认证机制细节**:
|
||||
- JWT (HS256) access_token(60分钟)+ refresh_token(30天)
|
||||
- 密码哈希:pbkdf2_sha256
|
||||
- 依赖注入:`get_current_user`, `require_admin`, `require_permission(permission_field)`, `get_user_permissions`
|
||||
|
||||
**权限系统(19 项细粒度权限)**:
|
||||
- 基础权限(默认开启):`can_view_dashboard`, `can_play_media`, `can_cast`, `can_external_player`, `can_favorite`, `can_view_history`
|
||||
- 受限功能(默认关闭):`can_edit_media`, `can_rescrape`, `can_use_ai`, `can_capture_frames`, `can_manage_downloads`, `can_view_discover`, `can_manage_subscriptions`, `can_manage_sites`, `can_use_ai_assistant`, `can_manage_users`, `can_manage_files`, `can_manage_strm`, `can_access_settings`
|
||||
- Plus 用户(tier=plus)自动获得所有权限
|
||||
- 管理员(role=admin)自动获得所有权限
|
||||
|
||||
**用户角色与层级**:
|
||||
- 角色:admin / user
|
||||
- 层级:free / plus(免费版限30用户,Plus 无限)
|
||||
|
||||
---
|
||||
|
||||
### 2.2 媒体库模块
|
||||
|
||||
> 源文件:`backend/app/media/` (router.py, service.py, repository.py, models.py, schemas.py, scanner.py, scraper.py, organizer.py, watcher.py, subtitle_service.py, duplicate.py, image_proxy.py, bangumi_scraper.py, douban_scraper.py, parse_code.py, providers/)
|
||||
|
||||
#### 2.2.1 媒体库管理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 媒体库列表 | GET | `/api/libraries` | 获取所有媒体库 | 登录 |
|
||||
| 创建媒体库 | POST | `/api/libraries` | 创建新媒体库 | 管理员 |
|
||||
| 扫描媒体库 | POST | `/api/libraries/{id}/scan` | 触发媒体库扫描+自动刮削 | 管理员 |
|
||||
| 更新媒体库 | PUT | `/api/libraries/{id}` | 更新媒体库配置 | 管理员 |
|
||||
| 删除媒体库 | DELETE | `/api/libraries/{id}` | 删除媒体库 | 管理员 |
|
||||
|
||||
#### 2.2.2 媒体条目管理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 媒体列表 | GET | `/api/media` | 分页+多维筛选(类型/类型/年份/评分/排序) | 登录 |
|
||||
| 最近添加 | GET | `/api/media/recent` | 获取最近添加的媒体 | 登录 |
|
||||
| 媒体统计 | GET | `/api/media/stats` | 获取媒体数量统计 | 登录 |
|
||||
| 媒体详情 | GET | `/api/media/{id}` | 获取媒体详情(含季/集/字幕) | 登录 |
|
||||
| 删除媒体 | DELETE | `/api/media/{id}` | 删除媒体条目 | 管理员 |
|
||||
| 更新媒体 | PUT | `/api/media/{id}` | 手动编辑媒体元数据 | 管理员 |
|
||||
| 视频截帧 | GET | `/api/media/{id}/thumbnail` | FFmpeg 视频截帧(缩略图) | 登录 |
|
||||
|
||||
#### 2.2.3 元数据刮削
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 刮削媒体 | POST | `/api/media/{id}/scrape` | 手动触发刮削(可指定 TMDb ID) | 管理员 |
|
||||
| 搜索 TMDb | GET | `/api/search/tmdb` | 搜索 TMDb 数据库 | 登录 |
|
||||
| 搜索豆瓣 | GET | `/api/search/douban` | 搜索豆瓣影视 | 登录 |
|
||||
| 搜索 Bangumi | GET | `/api/search/bangumi` | 搜索 Bangumi 动漫数据库 | 登录 |
|
||||
| Adult 刮削测试 | POST | `/api/media/scrape/test` | 测试 Adult Provider 刮削 | 管理员 |
|
||||
|
||||
**元数据 Provider Chain(多源聚合)**:
|
||||
- `TMDbProvider` — TMDb 主数据源(电影/剧集)
|
||||
- `DoubanProvider` — 豆瓣中文元数据补充
|
||||
- `BangumiProvider` — Bangumi 番剧/动画数据源
|
||||
- `AdultProvider` — 18+ 番号刮削(多层 Fallback:JavBus → JavDB → 微服务)
|
||||
- Provider Chain 支持优先级调度和自动降级
|
||||
|
||||
#### 2.2.4 搜索功能
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 全局搜索 | GET | `/api/search` | 搜索本地媒体库 | 登录 |
|
||||
| 高级搜索 | GET | `/api/search/advanced` | 多条件组合搜索(标题/类型/年份/评分/分辨率/字幕) | 登录 |
|
||||
| 混合搜索 | GET | `/api/search/mixed` | 并发搜索本地+TMDb | 登录 |
|
||||
|
||||
#### 2.2.5 推荐系统
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 智能推荐 | GET | `/api/recommend` | 基于高评分+热度的推荐 | 登录 |
|
||||
| 相似推荐 | GET | `/api/recommend/similar/{id}` | 基于同类型/标签/年代的相似内容 | 登录 |
|
||||
|
||||
#### 2.2.6 字幕管理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 字幕列表 | GET | `/api/media/{id}/subtitles` | 获取媒体字幕列表 | 登录 |
|
||||
| 扫描外挂字幕 | POST | `/api/media/{id}/subtitles/scan` | 扫描外挂字幕文件 | 管理员 |
|
||||
| 检测内嵌字幕 | POST | `/api/media/{id}/subtitles/extract` | 检测内嵌字幕流 | 管理员 |
|
||||
| 提取内嵌字幕 | POST | `/api/media/{id}/subtitles/extract/{idx}` | 提取内嵌字幕为 SRT | 管理员 |
|
||||
| 上传字幕 | POST | `/api/media/{id}/subtitles/upload` | 上传字幕文件 | 管理员 |
|
||||
| 获取字幕内容 | GET | `/api/subtitles/{id}/content` | 获取字幕文件内容 | 登录 |
|
||||
| 删除字幕 | DELETE | `/api/subtitles/{id}` | 删除字幕(可选删除文件) | 管理员 |
|
||||
|
||||
#### 2.2.7 收藏功能
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 添加收藏 | POST | `/api/media/{id}/favorite` | 添加收藏 | 登录 |
|
||||
| 取消收藏 | DELETE | `/api/media/{id}/favorite` | 取消收藏 | 登录 |
|
||||
| 检查收藏状态 | GET | `/api/media/{id}/favorite/status` | 检查是否已收藏 | 登录 |
|
||||
| 收藏列表 | GET | `/api/favorites` | 分页获取收藏列表 | 登录 |
|
||||
|
||||
#### 2.2.8 重复检测
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 计算文件哈希 | POST | `/api/libraries/{id}/duplicates/hash` | 计算文件哈希(重复检测前置) | 管理员 |
|
||||
| 检测重复 | POST | `/api/libraries/{id}/duplicates/scan` | 检测并标记重复文件 | 管理员 |
|
||||
| 重复文件列表 | GET | `/api/libraries/{id}/duplicates` | 获取重复文件列表 | 登录 |
|
||||
| 取消重复标记 | DELETE | `/api/libraries/{id}/duplicates` | 取消所有重复标记 | 管理员 |
|
||||
| 取消单项重复标记 | POST | `/api/media/{id}/duplicate/unmark` | 取消单个条目重复标记 | 管理员 |
|
||||
|
||||
#### 2.2.9 文件整理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 整理文件 | POST | `/api/media/organize` | 手动触发文件整理到媒体库 | 管理员 |
|
||||
|
||||
#### 2.2.10 图片代理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 本地图片访问 | GET | `/api/media/image/{filename}` | 访问本地保存的图片(防路径遍历) | 登录 |
|
||||
| 图片代理 | GET | `/api/media/proxy-image` | 代理外部图片(绕过防盗链) | 登录 |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 播放模块
|
||||
|
||||
> 源文件:`backend/app/playback/` (router.py, external.py, service.py, transcoder.py, models.py)
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 播放信息 | GET | `/api/playback/{id}/info` | 获取媒体播放信息 | 登录 |
|
||||
| 视频流 | GET | `/api/playback/{id}/stream` | 直接视频流(支持 Range 断点续传 + query token) | 登录 |
|
||||
| 外部播放器直链 | GET | `/api/playback/{id}/external-url` | 生成带 token 的外部播放直链 | 登录 |
|
||||
| 外部播放器协议 | GET | `/api/playback/{id}/external-players` | 生成各播放器协议直链(PotPlayer/VLC/IINA/Infuse/NPlayer/MX/MPV/MPC-HC) | 登录 |
|
||||
| 外部播放流 | GET | `/api/playback/{id}/external-stream` | 外部播放器流式传输(支持 Range) | Token |
|
||||
| HLS 播放列表 | GET | `/api/playback/hls/{job}/playlist.m3u8` | HLS m3u8 播放列表 | Token |
|
||||
| HLS 分片 | GET | `/api/playback/hls/{job}/{segment}` | HLS ts 分片 | Token |
|
||||
| 转码状态 | GET | `/api/playback/transcode/{job}/status` | 获取转码任务状态 | 登录 |
|
||||
| 字幕文件 | GET | `/api/playback/subtitles/{id}` | 获取字幕文件流 | 登录 |
|
||||
| 上报进度 | POST | `/api/playback/{id}/progress` | 上报播放进度 | 登录 |
|
||||
|
||||
**播放功能特性**:
|
||||
- HTTP Range 断点续传(206 Partial Content)
|
||||
- 多种认证方式:Bearer Token / Query Token / 一次性票据
|
||||
- 硬件加速转码(auto/qsv/vaapi/nvenc/videotoolbox/none)
|
||||
- HLS 转码输出
|
||||
- 外部播放器协议直链(8种播放器)
|
||||
- 转码并发控制(max_transcode_jobs)
|
||||
- 转码缓存自动清理
|
||||
|
||||
---
|
||||
|
||||
### 2.4 下载模块
|
||||
|
||||
> 源文件:`backend/app/download/` (router.py, service.py, clients.py, models.py, schemas.py)
|
||||
|
||||
#### 2.4.1 下载客户端管理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 客户端列表 | GET | `/api/download/clients` | 获取所有下载客户端 | 登录 |
|
||||
| 创建客户端 | POST | `/api/download/clients` | 添加下载客户端 | 管理员 |
|
||||
| 获取客户端 | GET | `/api/download/clients/{id}` | 获取客户端详情 | 登录 |
|
||||
| 更新客户端 | PUT | `/api/download/clients/{id}` | 更新客户端配置 | 管理员 |
|
||||
| 删除客户端 | DELETE | `/api/download/clients/{id}` | 删除客户端 | 管理员 |
|
||||
| 测试连接 | POST | `/api/download/clients/{id}/test` | 测试客户端连接 | 管理员 |
|
||||
|
||||
#### 2.4.2 下载任务管理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 任务列表 | GET | `/api/download/tasks` | 分页获取下载任务 | 登录 |
|
||||
| 添加任务 | POST | `/api/download/add` | 添加下载任务 | 登录 |
|
||||
| 暂停任务 | POST | `/api/download/{id}/pause` | 暂停下载 | 登录 |
|
||||
| 恢复任务 | POST | `/api/download/{id}/resume` | 恢复下载 | 登录 |
|
||||
| 删除任务 | DELETE | `/api/download/{id}` | 删除任务(可选删除文件) | 登录 |
|
||||
| 同步状态 | POST | `/api/download/sync` | 手动同步下载状态 | 管理员 |
|
||||
| 自动同步 | POST | `/api/download/start-auto-sync` | 启动后台自动进度同步(5秒间隔) | 登录 |
|
||||
|
||||
#### 2.4.3 Aria2 扩展
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| Aria2 统计 | GET | `/api/download/aria2/stats` | Aria2 全局统计(活跃/等待/停止/速度) | 登录 |
|
||||
|
||||
#### 2.4.4 整理入库
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 批量整理 | POST | `/api/download/organize` | 手动触发所有已完成任务整理入库 | 管理员 |
|
||||
| 单个整理 | POST | `/api/download/{id}/organize` | 手动整理单个下载任务 | 管理员 |
|
||||
|
||||
**下载客户端适配器**:
|
||||
- qBittorrent(WebUI API)
|
||||
- Transmission(RPC API)
|
||||
- Aria2(JSON-RPC)
|
||||
|
||||
---
|
||||
|
||||
### 2.5 订阅与站点模块
|
||||
|
||||
> 源文件:`backend/app/subscribe/` (router.py, service.py, site_adapter.py, notifier.py, models.py, schemas.py)
|
||||
|
||||
#### 2.5.1 站点管理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 站点列表 | GET | `/api/sites` | 获取所有站点配置 | 登录 |
|
||||
| 创建站点 | POST | `/api/sites` | 添加站点 | 管理员 |
|
||||
| 更新站点 | PUT | `/api/sites/{id}` | 更新站点配置 | 管理员 |
|
||||
| 删除站点 | DELETE | `/api/sites/{id}` | 删除站点 | 管理员 |
|
||||
| 测试站点 | POST | `/api/sites/{id}/test` | 测试站点连接 | 管理员 |
|
||||
| 浏览站点资源 | GET | `/api/sites/{id}/resource` | 分页浏览站点资源列表 | 登录 |
|
||||
| 刷新用户数据 | GET | `/api/sites/{id}/userdata` | 获取站点用户数据(上传/下载量等) | 管理员 |
|
||||
|
||||
**支持站点类型**:
|
||||
- **NexusPHP** — 国内绝大多数 PT 站
|
||||
- **Gazelle/Luminance** — HDBits/OPS 等
|
||||
- **UNIT3D** — BeyondHD/BluTopia 等
|
||||
- **MTeam** — 馒头专用 REST API
|
||||
- **Discuz** — 论坛型资源站
|
||||
- **Custom RSS** — 自定义 RSS
|
||||
|
||||
**认证方式**:
|
||||
- Cookie / API Key / Authorization Header
|
||||
|
||||
#### 2.5.2 资源搜索(跨站聚合)
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 跨站搜索 | GET | `/api/search/sites` | 多站点资源聚合搜索 | 登录 |
|
||||
|
||||
#### 2.5.3 订阅管理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 订阅列表 | GET | `/api/subscriptions` | 获取订阅列表 | 登录 |
|
||||
| 创建订阅 | POST | `/api/subscriptions` | 创建订阅 | 登录 |
|
||||
| 更新订阅 | PUT | `/api/subscriptions/{id}` | 更新订阅 | 登录 |
|
||||
| 删除订阅 | DELETE | `/api/subscriptions/{id}` | 删除订阅 | 管理员 |
|
||||
| 按媒体查订阅 | GET | `/api/subscriptions/media/{mediaid}` | 支持 tmdb:/douban:/bangumi: 前缀 | 登录 |
|
||||
| 触发搜索 | POST | `/api/subscriptions/{id}/search` | 手动触发订阅搜索 | 登录 |
|
||||
| 分享订阅 | POST | `/api/subscriptions/{id}/share` | 创建订阅分享 | 登录 |
|
||||
| 复制订阅 | POST | `/api/subscriptions/{id}/fork` | 从分享链接复制订阅 | 登录 |
|
||||
|
||||
**订阅过滤条件**:
|
||||
- 画质优先级列表
|
||||
- 最小/最大文件大小
|
||||
- 包含/排除关键词
|
||||
|
||||
#### 2.5.4 通知渠道
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 渠道列表 | GET | `/api/notify/channels` | 获取通知渠道列表 | 登录 |
|
||||
| 创建渠道 | POST | `/api/notify/channels` | 创建通知渠道 | 管理员 |
|
||||
| 更新渠道 | PUT | `/api/notify/channels/{id}` | 更新通知渠道 | 管理员 |
|
||||
| 删除渠道 | DELETE | `/api/notify/channels/{id}` | 删除通知渠道 | 管理员 |
|
||||
| 测试渠道 | POST | `/api/notify/channels/{id}/test` | 发送测试通知 | 管理员 |
|
||||
|
||||
**通知渠道类型**:
|
||||
- Telegram
|
||||
- 微信(Server酱)
|
||||
- Bark (iOS)
|
||||
- Webhook
|
||||
- Email
|
||||
|
||||
#### 2.5.5 RSS
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 拉取 RSS | POST | `/api/rss/pull` | 手动拉取所有站点 RSS | 管理员 |
|
||||
|
||||
---
|
||||
|
||||
### 2.6 系统模块
|
||||
|
||||
> 源文件:`backend/app/system/` (router.py, settings_router.py, settings_service.py, api_config_router.py, api_config_service.py, api_config_models.py, scheduler.py, events.py, crypto.py, models.py)
|
||||
|
||||
#### 2.6.1 系统信息
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 健康检查 | GET | `/api/health` | 健康检查端点 | 公开 |
|
||||
| 系统信息 | GET | `/api/system/info` | 获取系统详细信息 | 登录 |
|
||||
| 系统状态 | GET | `/api/system/status` | CPU/内存/磁盘使用率 | 登录 |
|
||||
| 系统配置 | GET | `/api/system/config` | 获取可编辑系统配置(密钥掩码) | 管理员 |
|
||||
| 更新系统配置 | PATCH | `/api/system/config` | 更新系统配置(写入 .env) | 管理员 |
|
||||
|
||||
#### 2.6.2 SSE 实时事件
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 获取 SSE 票据 | GET | `/api/system/events/ticket` | 生成一次性 SSE 票据(OTP,10秒有效) | 登录 |
|
||||
| SSE 事件流 | GET | `/api/system/events` | SSE 实时事件推送 | 登录 |
|
||||
|
||||
**SSE 安全机制**:
|
||||
- 一次性票据(OTP)认证(推荐,防 Nginx 日志泄露 JWT)
|
||||
- 兼容 Authorization Header 认证
|
||||
- 兼容 URL query token 认证(旧版)
|
||||
|
||||
**事件类型**:下载进度、扫描进度、通知消息等
|
||||
|
||||
#### 2.6.3 定时任务
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 调度器信息 | GET | `/api/system/scheduler` | 获取定时任务列表 | 管理员 |
|
||||
| 触发任务 | POST | `/api/system/scheduler/{id}/trigger` | 手动触发定时任务 | 管理员 |
|
||||
|
||||
**内置定时任务**:
|
||||
| 任务 ID | 名称 | 间隔 | 说明 |
|
||||
|---------|------|------|------|
|
||||
| `media_scan` | 媒体库扫描 | 60分钟 | 扫描+增量刮削 |
|
||||
| `subscription_search` | 订阅搜索 | 60分钟 | 处理所有订阅 |
|
||||
| `download_sync` | 下载状态同步 | 30秒 | 同步下载进度 |
|
||||
| `rss_pull` | RSS 拉取 | 30分钟 | 拉取所有站点 RSS |
|
||||
| `cache_cleanup` | 转码缓存清理 | 每天3:00 | 清理24小时以上的转码缓存 |
|
||||
| `download_complete` | 下载完成整理 | 5分钟 | 检测完成并自动整理入库 |
|
||||
|
||||
#### 2.6.4 整理与刮削配置
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 获取所有配置 | GET | `/api/settings` | 获取所有整理/刮削配置 | 管理员 |
|
||||
| 配置 Schema | GET | `/api/settings/schema` | 获取配置表单 Schema | 管理员 |
|
||||
| 获取单个配置 | GET | `/api/settings/{key}` | 获取单个配置 | 管理员 |
|
||||
| 更新单个配置 | PUT | `/api/settings/{key}` | 更新单个配置 | 管理员 |
|
||||
| 批量更新 | PATCH | `/api/settings` | 批量更新配置 | 管理员 |
|
||||
| 重置配置 | DELETE | `/api/settings/{key}` | 重置为默认值 | 管理员 |
|
||||
| 重置所有 | DELETE | `/api/settings` | 重置所有配置 | 管理员 |
|
||||
|
||||
#### 2.6.5 API 配置管理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 配置列表 | GET | `/api/api-config` | 获取所有 API 配置 | 管理员 |
|
||||
| 获取配置 | GET | `/api/api-config/{provider}` | 获取指定 Provider 配置 | 管理员 |
|
||||
| 获取生效配置 | GET | `/api/api-config/{provider}/effective` | 获取合并后的生效配置 | 管理员 |
|
||||
| 更新配置 | POST | `/api/api-config/{provider}` | 更新 API 配置 | 管理员 |
|
||||
| 清除配置 | DELETE | `/api/api-config/{provider}` | 清除 API Key | 管理员 |
|
||||
| 测试连接 | POST | `/api/api-config/{provider}/test` | 测试 API 连接 | 管理员 |
|
||||
| Provider 列表 | GET | `/api/api-config/providers/list` | 获取支持的 Provider 列表 | 管理员 |
|
||||
|
||||
**预置 Provider**:
|
||||
- `tmdb` — TMDb API
|
||||
- `douban` — 豆瓣
|
||||
- `bangumi` — Bangumi
|
||||
- `thetvdb` — TheTVDB
|
||||
- `fanart` — Fanart.tv
|
||||
- `openai` — OpenAI 兼容 API
|
||||
- `siliconflow` — 硅基流动
|
||||
- `deepseek` — DeepSeek
|
||||
- `adult` — Adult Provider (JavBus/JavDB)
|
||||
|
||||
#### 2.6.6 敏感数据加密
|
||||
|
||||
> 源文件:`backend/app/system/crypto.py`
|
||||
|
||||
- 使用 Fernet (AES-128-CBC) 加密存储 API Key、Passkey 等敏感字段
|
||||
- 基于 APP_SECRET_KEY 派生加密密钥
|
||||
- 加密数据前缀标识 `enc:v1:`,兼容旧版明文迁移
|
||||
|
||||
---
|
||||
|
||||
### 2.7 管理后台模块
|
||||
|
||||
> 源文件:`backend/app/admin/` (router.py, service.py, schemas.py, backup_service.py)
|
||||
|
||||
#### 2.7.1 定时任务管理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 |
|
||||
|------|----------|------|------|
|
||||
| 定时任务列表 | GET | `/api/admin/scheduler/tasks` | 获取可管理的定时任务 |
|
||||
| 创建定时任务 | POST | `/api/admin/scheduler/tasks` | 创建自定义定时任务 |
|
||||
| 更新定时任务 | PUT | `/api/admin/scheduler/tasks/{id}` | 更新定时任务 |
|
||||
| 删除定时任务 | DELETE | `/api/admin/scheduler/tasks/{id}` | 删除定时任务 |
|
||||
|
||||
#### 2.7.2 批量操作
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 |
|
||||
|------|----------|------|------|
|
||||
| 批量扫描 | POST | `/api/admin/media/batch/scan` | 批量扫描媒体库 |
|
||||
| 批量刮削 | POST | `/api/admin/media/batch/scrape` | 批量刮削媒体 |
|
||||
| 批量删除 | POST | `/api/admin/media/batch/delete` | 批量删除媒体 |
|
||||
| 批量移动 | POST | `/api/admin/media/batch/move` | 批量移动媒体到其他库 |
|
||||
| 批量收藏 | POST | `/api/admin/media/batch/favorite` | 批量收藏 |
|
||||
| 批量标记已看 | POST | `/api/admin/media/batch/watched` | 批量标记为已看 |
|
||||
| 批量重命名 | POST | `/api/admin/media/batch/rename` | 批量重命名文件 |
|
||||
| AI 重命名 | POST | `/api/admin/media/batch/ai-rename` | AI 智能重命名 |
|
||||
|
||||
#### 2.7.3 内容分级
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 |
|
||||
|------|----------|------|------|
|
||||
| 获取分级 | GET | `/api/admin/content-rating` | 获取内容分级配置 |
|
||||
| 更新分级 | PUT | `/api/admin/content-rating` | 更新内容分级 |
|
||||
|
||||
#### 2.7.4 文件管理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 |
|
||||
|------|----------|------|------|
|
||||
| 浏览文件 | GET | `/api/admin/files/browse` | 浏览文件目录 |
|
||||
| 文件操作 | POST | `/api/admin/files/operation` | 文件操作(移动/复制/删除) |
|
||||
| 重命名预览 | GET | `/api/admin/files/rename/preview` | 重命名预览 |
|
||||
| 批量重命名预览 | POST | `/api/admin/files/rename/batch-preview` | 批量重命名预览 |
|
||||
| 执行重命名 | POST | `/api/admin/files/rename/execute` | 执行重命名 |
|
||||
| 创建文件夹 | POST | `/api/admin/files/folder` | 创建文件夹 |
|
||||
| 重命名文件夹 | PUT | `/api/admin/files/folder/{path}` | 重命名文件夹 |
|
||||
| 删除文件夹 | DELETE | `/api/admin/files/folder/{path}` | 删除文件夹 |
|
||||
|
||||
#### 2.7.5 系统管理
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 |
|
||||
|------|----------|------|------|
|
||||
| 系统设置 | GET/PUT | `/api/admin/settings` | 获取/更新系统设置 |
|
||||
| 系统统计 | GET | `/api/admin/stats` | 获取系统统计信息 |
|
||||
| 系统备份 | POST | `/api/admin/backup` | 触发系统备份 |
|
||||
| 备份列表 | GET | `/api/admin/backup/list` | 获取备份列表 |
|
||||
| 恢复备份 | POST | `/api/admin/backup/restore` | 从备份恢复 |
|
||||
|
||||
---
|
||||
|
||||
### 2.8 统计模块
|
||||
|
||||
> 源文件:`backend/app/stats/` (router.py, service.py, schemas.py)
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 概览统计 | GET | `/api/stats/overview` | 媒体总数/电影/剧集/大小/用户/播放次数 | 公开 |
|
||||
| 播放趋势 | GET | `/api/stats/trend` | 按小时/天/周的播放趋势 | 公开 |
|
||||
| 热门内容 | GET | `/api/stats/top-content` | 播放次数最多的媒体 | 公开 |
|
||||
| 活跃用户 | GET | `/api/stats/top-users` | 播放次数最多的用户 | 公开 |
|
||||
| 媒体库统计 | GET | `/api/stats/libraries` | 各媒体库统计 | 管理员 |
|
||||
| 系统监控 | GET | `/api/stats/monitor` | CPU/内存/磁盘/网络监控 | 管理员 |
|
||||
| 用户统计 | GET | `/api/stats/user/{id}` | 用户播放统计 | 管理员 |
|
||||
| 记录播放 | POST | `/api/stats/play` | 记录播放事件 | 登录 |
|
||||
|
||||
---
|
||||
|
||||
### 2.9 播放列表模块
|
||||
|
||||
> 源文件:`backend/app/playlist/` (router.py, service.py, models.py, schemas.py)
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 播放列表列表 | GET | `/api/playlists` | 获取用户播放列表 | 登录 |
|
||||
| 播放列表详情 | GET | `/api/playlists/{id}` | 获取列表详情(含媒体项) | 登录 |
|
||||
| 创建播放列表 | POST | `/api/playlists` | 创建播放列表 | 登录 |
|
||||
| 更新播放列表 | PUT | `/api/playlists/{id}` | 更新播放列表 | 登录 |
|
||||
| 删除播放列表 | DELETE | `/api/playlists/{id}` | 删除播放列表 | 登录 |
|
||||
| 添加项目 | POST | `/api/playlists/{id}/items` | 添加媒体到播放列表 | 登录 |
|
||||
| 移除项目 | DELETE | `/api/playlists/{id}/items/{item_id}` | 从播放列表移除 | 登录 |
|
||||
| 重新排序 | PUT | `/api/playlists/{id}/reorder` | 重新排序播放列表 | 登录 |
|
||||
|
||||
---
|
||||
|
||||
### 2.10 STRM 文件支持模块
|
||||
|
||||
> 源文件:`backend/app/strm/` (router.py, schemas.py, __init__.py)
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| STRM 配置 | GET | `/api/admin/strm/config` | 获取 STRM 配置 | 管理员 |
|
||||
| 更新 STRM 配置 | PUT | `/api/admin/strm/config` | 更新 STRM 配置 | 管理员 |
|
||||
| 获取 STRM URL | GET | `/api/admin/strm/media/{id}` | 获取媒体 STRM URL | 管理员 |
|
||||
| 设置 STRM URL | PUT | `/api/admin/strm/media/{id}` | 设置媒体 STRM URL(协议白名单校验) | 管理员 |
|
||||
| 清除 STRM URL | DELETE | `/api/admin/strm/media/{id}` | 清除 STRM URL | 管理员 |
|
||||
| Emby STRM 播放信息 | GET | `/api/admin/strm/emby/Items/{id}/PlaybackInfo` | Emby 兼容 STRM 播放 | 公开 |
|
||||
|
||||
**STRM 功能**:将外部存储(WebDAV/Alist/S3/HTTP 直链)以"文件"形式加入媒体库,播放时直接访问远程 URL。
|
||||
|
||||
---
|
||||
|
||||
### 2.11 DLNA/投屏模块
|
||||
|
||||
> 源文件:`backend/app/dlna/__init__.py`(当前为 stub 实现)
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 发现设备 | GET | `/api/dlna/devices` | 发现 DLNA 设备 | 登录 |
|
||||
| 获取设备 | GET | `/api/dlna/devices/{id}` | 获取设备信息 | 登录 |
|
||||
| 投屏 | POST | `/api/dlna/cast` | 投屏媒体到设备 | 登录 |
|
||||
|
||||
> **注意**:当前 DLNA 为 stub 实现,返回空列表。Go 版可考虑完整实现。
|
||||
|
||||
---
|
||||
|
||||
### 2.12 授权管理模块
|
||||
|
||||
> 源文件:`backend/app/license/` (router.py, schemas.py, __init__.py)
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 授权信息 | GET | `/api/license/info` | 获取基本授权信息 | 登录 |
|
||||
| 授权状态 | GET | `/api/license/status` | 获取详细授权状态 | 登录 |
|
||||
| 激活授权 | POST | `/api/license/activate` | 通过授权码激活 Plus | 登录 |
|
||||
| 解绑授权 | POST | `/api/license/unbind` | 解绑当前设备 | 登录 |
|
||||
| 授权配置 | GET/POST | `/api/license/config` | 获取/更新授权配置 | 管理员 |
|
||||
| 测试连接 | POST | `/api/license/config/test` | 测试授权服务器连接 | 管理员 |
|
||||
| 心跳状态 | GET | `/api/license/heartbeat-status` | 获取心跳状态 | 登录 |
|
||||
| 刷新授权 | POST | `/api/license/refresh` | 刷新授权状态 | 登录 |
|
||||
| 生成授权码 | POST | `/api/license/generate` | 生成授权码 | 管理员 |
|
||||
| 授权码列表 | GET | `/api/license/list` | 获取授权码列表 | 管理员 |
|
||||
| 激活记录 | GET | `/api/license/{id}/activations` | 获取激活记录 | 管理员 |
|
||||
| 吊销授权码 | POST | `/api/license/{id}/revoke` | 吊销授权码 | 管理员 |
|
||||
| 解绑设备 | POST | `/api/license/activation/{id}/unbind` | 解绑指定设备 | 管理员 |
|
||||
|
||||
**Plus 版特性**:
|
||||
- 无用户数量限制(免费版限30人)
|
||||
- Plus 用户自动获得所有功能权限
|
||||
- 授权码格式:`MS-XXXX-XXXX-XXXX-XXXX`
|
||||
- 验证模式:本地验证 / 在线服务器验证
|
||||
|
||||
---
|
||||
|
||||
### 2.13 Emby API 兼容层
|
||||
|
||||
> 源文件:`backend/app/emby_api.py`(~1800 行,完整的 Emby Server API v3 兼容)
|
||||
|
||||
提供 Emby API 子集,让 **Infuse、Kodi、Fileball** 等客户端可以直接连接 MediaStation。
|
||||
|
||||
**核心 Emby 端点**(仅列出关键部分,实际约 50+ 端点):
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 |
|
||||
|------|----------|------|------|
|
||||
| Emby 认证 | POST | `/api/emby/Users/AuthenticateByName` | Emby 客户端认证 |
|
||||
| 系统信息 | GET | `/api/emby/System/Info` | Emby 系统信息 |
|
||||
| 媒体库列表 | GET | `/api/emby/Library/VirtualFolders` | 虚拟文件夹(媒体库) |
|
||||
| 媒体列表 | GET | `/api/emby/Items` | 媒体条目列表 |
|
||||
| 媒体详情 | GET | `/api/emby/Users/{uid}/Items/{id}` | 媒体详情 |
|
||||
| 搜索 | GET | `/api/emby/Items?searchTerm=` | 媒体搜索 |
|
||||
| 播放信息 | GET | `/api/emby/Items/{id}/PlaybackInfo` | 获取播放流信息 |
|
||||
| 视频流 | GET | `/api/emby/Videos/{id}/stream` | 视频流直链 |
|
||||
| 字幕流 | GET | `/api/emby/Videos/{id}/Subtitles/{sid}/Stream` | 字幕流 |
|
||||
| 播放进度上报 | POST | `/api/emby/Sessions/Playing` | 上报播放进度 |
|
||||
| 播放停止 | POST | `/api/emby/Sessions/Playing/Stopped` | 播放停止上报 |
|
||||
| 最新添加 | GET | `/api/emby/Users/{uid}/Items/Latest` | 最新添加的媒体 |
|
||||
| 继续观看 | GET | `/api/emby/Users/{uid}/Items/Resume` | 继续观看列表 |
|
||||
|
||||
**Emby 认证方式**:
|
||||
- X-Emby-Token Header
|
||||
- Authorization: Bearer Token
|
||||
- Emby 用户名/密码认证
|
||||
|
||||
---
|
||||
|
||||
### 2.14 发现/探索模块
|
||||
|
||||
> 源文件:`backend/app/media/discover_router.py`
|
||||
|
||||
| 功能 | HTTP 方法 | 端点 | 说明 | 权限 |
|
||||
|------|----------|------|------|------|
|
||||
| 可用区块列表 | GET | `/api/discover/sections` | 获取所有推荐区块(含可用状态) | 登录 |
|
||||
| 聚合发现页 | GET | `/api/discover/feed` | 聚合各数据源推荐内容 | 登录 |
|
||||
| 图片代理 | GET | `/api/discover/image-proxy` | 代理外部图片(绕过豆瓣防盗链) | 登录 |
|
||||
|
||||
**推荐区块(12 个)**:
|
||||
| Key | 标签 | 数据源 |
|
||||
|-----|------|--------|
|
||||
| `recent_movies` | 最近添加电影 | 本地 |
|
||||
| `recent_tv` | 最近添加剧集 | 本地 |
|
||||
| `top_rated` | 评分最高 | 本地 |
|
||||
| `anime` | 动漫推荐 | 本地 |
|
||||
| `tmdb_trending` | 流行趋势 | TMDb |
|
||||
| `tmdb_now_playing` | 正在热映 | TMDb |
|
||||
| `tmdb_popular_movies` | TMDB 热门电影 | TMDb |
|
||||
| `tmdb_popular_tv` | TMDB 热门电视剧 | TMDb |
|
||||
| `douban_hot_movies` | 豆瓣热门电影 | 豆瓣 |
|
||||
| `douban_hot_tv` | 豆瓣热门电视剧 | 豆瓣 |
|
||||
| `douban_hot_anime` | 豆瓣热门动漫 | 豆瓣 |
|
||||
| `douban_top250` | 豆瓣 TOP250 | 豆瓣 |
|
||||
| `bangumi_daily` | Bangumi 每日放送 | Bangumi |
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型清单
|
||||
|
||||
> 源文件:`backend/app/base_models.py`, 各模块 `models.py`
|
||||
|
||||
### 公共基类
|
||||
|
||||
| 模型 | 说明 |
|
||||
|------|------|
|
||||
| `Base` | SQLAlchemy 声明基类 |
|
||||
| `TimestampMixin` | 时间戳混入(created_at, updated_at) |
|
||||
|
||||
### 用户模块
|
||||
|
||||
| 表名 | 模型 | 关键字段 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `users` | User | id, username, password_hash, role, tier, avatar, nickname, is_active, last_login | 用户表 |
|
||||
| `user_permissions` | UserPermission | id, user_id, can_* (19个权限字段) | 用户功能权限表 |
|
||||
| `system_config` | SystemConfig | id, key, value, value_type | 系统配置表 |
|
||||
| `watch_history` | WatchHistory | id, user_id, media_item_id, episode_id, progress, duration, completed, last_watched | 观看历史 |
|
||||
|
||||
### 媒体模块
|
||||
|
||||
| 表名 | 模型 | 关键字段 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `media_libraries` | MediaLibrary | id, name, path, media_type, scan_interval, enabled, min_file_size, metadata_language, adult_content, prefer_nfo, enable_watch | 媒体库 |
|
||||
| `media_items` | MediaItem | id, library_id, tmdb_id, douban_id, bangumi_id, title, original_title, year, overview, poster_url, backdrop_url, media_type, rating, genres, file_path, file_size, duration, video/audio_codec, resolution, strm_url, hdr_format, audio_channels, frame_rate, color_space, bit_depth, is_duplicate, duplicate_of, file_hash | 媒体条目 |
|
||||
| `media_seasons` | MediaSeason | id, media_item_id, season_number, name, poster_url | 季 |
|
||||
| `media_episodes` | MediaEpisode | id, season_id, episode_number, title, file_path, file_size, duration, air_date, video/audio_codec | 集 |
|
||||
| `subtitles` | Subtitle | id, media_item_id, episode_id, language, language_name, path, source | 字幕 |
|
||||
| `favorites` | Favorite | id, user_id, media_item_id (unique) | 收藏 |
|
||||
|
||||
### 下载模块
|
||||
|
||||
| 表名 | 模型 | 关键字段 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `download_clients` | DownloadClient | id, name, client_type, host, port, username, password, enabled, category | 下载客户端 |
|
||||
| `download_tasks` | DownloadTask | id, client_id, subscription_id, media_id, torrent_name, torrent_url, info_hash, save_path, status, progress, total_size, downloaded, speed, seeders, eta, message | 下载任务 |
|
||||
|
||||
### 订阅模块
|
||||
|
||||
| 表名 | 模型 | 关键字段 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `sites` | Site | id, name, base_url, site_type, auth_type, cookie, api_key, auth_header, user_agent, rss_url, timeout, priority, use_proxy, rate_limit, browser_emulation, enabled, login_status, upload/download_bytes, downloader | 站点配置 |
|
||||
| `subscriptions` | Subscription | id, name, original_name, tmdb_id, media_type, year, quality_filter, min/max_size, exclude/include_keywords, status, last_search, total_downloaded | 订阅 |
|
||||
| `subscription_logs` | SubscriptionLog | id, subscription_id, action, resource_title, message | 订阅日志 |
|
||||
| `notify_channels` | NotifyChannel | id, name, channel_type, config, enabled, events | 通知渠道 |
|
||||
|
||||
### 播放模块
|
||||
|
||||
| 表名 | 模型 | 关键字段 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `play_history` | PlayHistory | id, user_id, media_item_id, played_at, duration, device_type, ip_address | 播放历史 |
|
||||
| `playlists` | Playlist | id, user_id, name, description, cover_url, is_public | 播放列表 |
|
||||
| `playlist_items` | PlaylistItem | id, playlist_id, media_item_id, position, added_at | 播放列表项 |
|
||||
|
||||
### 系统模块
|
||||
|
||||
| 表名 | 模型 | 关键字段 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `settings` | SettingsKV | id, key, value | KV 设置表 |
|
||||
| `api_configs` | ApiConfig | id, provider, api_key, base_url, extra, enabled, description | API 配置表 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 前端功能清单
|
||||
|
||||
> 源文件:`frontend/src/`
|
||||
|
||||
### 4.1 页面/视图
|
||||
|
||||
| 路由 | 视图文件 | 功能 | 权限 |
|
||||
|------|---------|------|------|
|
||||
| `/login` | LoginView.vue | 登录页 | 公开 |
|
||||
| `/` | DashboardView.vue | 仪表盘(继续观看/最近添加/统计数据) | can_view_dashboard |
|
||||
| `/media` | MediaLibraryView.vue | 媒体库浏览(列表/海报墙切换) | can_play_media |
|
||||
| `/poster-wall` | PosterWallView.vue | 海报墙视图 | can_play_media |
|
||||
| `/favorites` | FavoritesView.vue | 收藏列表 | can_favorite |
|
||||
| `/tv/:id` | TvSeasonView.vue | 剧集季详情 | can_play_media |
|
||||
| `/media/:id` | MediaDetailView.vue | 媒体详情页 | can_play_media |
|
||||
| `/player/:id` | PlayerView.vue | 视频播放器 | can_play_media |
|
||||
| `/downloads` | DownloadView.vue | 下载管理 | can_manage_downloads |
|
||||
| `/discover` | DiscoverView.vue | 发现/探索页(多源聚合) | can_view_discover |
|
||||
| `/search` | SearchResultView.vue | 搜索结果页 | 登录 |
|
||||
| `/subscriptions` | SubscribeView.vue | 订阅管理 | can_manage_subscriptions |
|
||||
| `/sites` | SitesView.vue | 站点管理 | can_manage_sites |
|
||||
| `/site-search` | SiteSearchView.vue | 跨站资源搜索 | can_manage_sites |
|
||||
| `/settings` | SettingsView.vue | 系统设置(多 Tab) | can_access_settings |
|
||||
| `/history` | WatchHistoryView.vue | 观看历史 | can_view_history |
|
||||
| `/profile` | ProfileView.vue | 个人资料 | 登录 |
|
||||
| `/files` | FileManagerView.vue | 文件管理器 | can_manage_files |
|
||||
| `/playlists` | PlaylistView.vue | 播放列表 | 登录 |
|
||||
| `/playlists/:id` | PlaylistDetailView.vue | 播放列表详情 | 登录 |
|
||||
| `/ai-assistant` | AIAssistantView.vue | AI 助手 | can_use_ai_assistant |
|
||||
| `/profiles-management` | ProfileManagementView.vue | 用户管理 | 管理员 |
|
||||
| `/storage` | StorageView.vue | 存储管理 | 管理员 |
|
||||
| `/strm` | StrmView.vue | STRM 文件管理 | can_manage_strm |
|
||||
| `/dlna` | DlnaView.vue | DLNA 投屏 | can_cast |
|
||||
|
||||
### 4.2 组件
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| AppEmpty.vue | 空状态占位组件 |
|
||||
| AppModal.vue | 通用模态框 |
|
||||
| AppToast.vue | 消息提示 |
|
||||
| BackendStatus.vue | 后端状态检测 |
|
||||
| FileTree.vue / FileTreeNode.vue | 文件树组件 |
|
||||
| settings/GeneralTab.vue | 通用设置 Tab |
|
||||
| settings/AccountTab.vue | 账户设置 Tab |
|
||||
| settings/UsersTab.vue | 用户管理 Tab |
|
||||
| settings/LibrariesTab.vue | 媒体库设置 Tab |
|
||||
| settings/OrganizeScrapeTab.vue | 整理与刮削设置 Tab |
|
||||
| settings/DownloadTab.vue | 下载设置 Tab |
|
||||
| settings/NotifyTab.vue | 通知设置 Tab |
|
||||
| settings/SchedulerTab.vue | 定时任务设置 Tab |
|
||||
| settings/SystemTab.vue | 系统设置 Tab |
|
||||
| settings/ApiConfigTab.vue | API 配置 Tab |
|
||||
| settings/LicenseTab.vue | 授权管理 Tab |
|
||||
| settings/AdultTab.vue | Adult Provider 设置 Tab |
|
||||
| settings/ConfigGroup.vue / ConfigRow.vue | 配置表单通用组件 |
|
||||
|
||||
### 4.3 状态管理(Pinia Stores)
|
||||
|
||||
| Store | 文件 | 说明 |
|
||||
|-------|------|------|
|
||||
| auth | stores/auth.ts | 认证状态 + 用户权限 |
|
||||
| player | stores/player.ts | 播放器状态 |
|
||||
|
||||
### 4.4 API 调用模块
|
||||
|
||||
| 模块 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| auth | api/auth.ts | 认证相关 API |
|
||||
| media | api/media.ts | 媒体库 API |
|
||||
| playback | api/playback.ts | 播放 API |
|
||||
| download | api/download.ts | 下载 API |
|
||||
| subscribe | api/subscribe.ts | 订阅 API |
|
||||
| system | api/system.ts | 系统 API |
|
||||
| settings | api/settings.ts | 设置 API |
|
||||
| config | api/config.ts | 配置 API |
|
||||
| admin | api/admin.ts | 管理后台 API |
|
||||
| license | api/license.ts | 授权 API |
|
||||
| profiles | api/profiles.ts | 用户配置 API |
|
||||
| playlist | api/playlist.ts | 播放列表 API |
|
||||
| strm | api/strm.ts | STRM API |
|
||||
| dlna | api/dlna.ts | DLNA API |
|
||||
| client | api/client.ts | HTTP 客户端封装 |
|
||||
|
||||
### 4.5 Composables
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| useFormat.ts | 格式化工具(文件大小、时长等) |
|
||||
| useImageError.ts | 图片加载错误处理(默认占位图) |
|
||||
| useSSE.ts | SSE 实时事件连接 |
|
||||
| useToast.ts | 消息提示封装 |
|
||||
|
||||
### 4.6 前端路由守卫
|
||||
|
||||
- 认证检查(requiresAuth)
|
||||
- 游客页面重定向(guest)
|
||||
- 管理员权限检查(adminOnly)
|
||||
- 功能权限检查(requiredPermission)— 与后端 19 项权限对齐
|
||||
|
||||
---
|
||||
|
||||
## 5. 部署配置清单
|
||||
|
||||
### 5.1 Docker
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `docker/Dockerfile` | 多阶段构建(前端构建 + Python 运行时) |
|
||||
| `docker/docker-compose.yml` | Docker Compose 编排 |
|
||||
| `docker/docker-compose.template.yml` | 模板版本 |
|
||||
| `docker/.env.template` | 环境变量模板 |
|
||||
| `docker/deploy-docker.sh` | Linux 部署脚本 |
|
||||
| `docker/deploy-docker.ps1` | Windows 部署脚本 |
|
||||
| `docker/check-image-security.sh` | 镜像安全检查 |
|
||||
| `docker-compose.example.yml` | 根目录示例 |
|
||||
|
||||
### 5.2 Helm Chart
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `deploy/charts/helm/Chart.yaml` | Chart 定义 |
|
||||
| `deploy/charts/helm/values.yaml` | 默认值 |
|
||||
| `deploy/charts/helm/templates/deployment.yaml` | Deployment 模板 |
|
||||
| `deploy/charts/helm/templates/service.yaml` | Service 模板 |
|
||||
| `deploy/charts/helm/templates/ingress.yaml` | Ingress 模板 |
|
||||
| `deploy/charts/helm/templates/pvc.yaml` | PVC 模板 |
|
||||
| `deploy/charts/helm/templates/secret.yaml` | Secret 模板 |
|
||||
| `deploy/charts/helm/templates/serviceaccount.yaml` | ServiceAccount 模板 |
|
||||
| `deploy/charts/helm/templates/_helpers.tpl` | Helm 辅助模板 |
|
||||
|
||||
### 5.3 其他部署
|
||||
|
||||
| 文件/目录 | 说明 |
|
||||
|-----------|------|
|
||||
| `nginx.example.conf` | Nginx 反向代理配置(含 SSE/视频流/HTTPS) |
|
||||
| `deploy/scripts/install-linux.sh` | Linux 安装脚本 |
|
||||
| `deploy/scripts/install-windows.ps1` | Windows 安装脚本 |
|
||||
| `deploy/templates/unraid/mediastation.xml` | Unraid 模板 |
|
||||
| `deploy/charts/truenas/media-station.yaml` | TrueNAS Chart |
|
||||
| `deploy/packages/synology/build-spk.sh` | 群晖 SPK 打包 |
|
||||
| `deploy/DEPLOYMENT.md` | 部署文档 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 中间件与基础设施
|
||||
|
||||
| 功能 | 源文件 | 说明 |
|
||||
|------|--------|------|
|
||||
| CORS 中间件 | main.py | 可配置 origins,支持凭证 |
|
||||
| 全局异常处理 | main.py | AppError 层级 + 422/500 兜底 |
|
||||
| SPA 路由回退 | main.py | 非API请求返回 index.html |
|
||||
| 路径遍历防护 | main.py, image_proxy.py | resolve() 后校验 |
|
||||
| SQLite WAL 模式 | database.py | 预设 WAL + NORMAL 同步 |
|
||||
| SQLite busy_timeout | database.py | 5000ms 忙等待 |
|
||||
| PostgreSQL 连接池 | database.py | pool_size=10, max_overflow=20 |
|
||||
| JWT 认证 | deps.py, user/auth.py | HS256, access + refresh token |
|
||||
| 权限检查 | deps.py | require_permission() 工厂函数 |
|
||||
| 敏感数据加密 | system/crypto.py | Fernet (AES-128-CBC) |
|
||||
| SSE 事件总线 | system/events.py | 僵尸队列检测 + 心跳 + 自动清理 |
|
||||
| 文件监控 | media/watcher.py | 文件系统实时监控 |
|
||||
| 后台任务调度 | system/scheduler.py | APScheduler (AsyncIO) |
|
||||
|
||||
### 异常层级
|
||||
|
||||
| 异常类 | HTTP 状态码 | 说明 |
|
||||
|--------|-----------|------|
|
||||
| AppError | 500 | 基础业务异常 |
|
||||
| NotFoundError | 404 | 资源不存在 |
|
||||
| ValidationError | 422 | 参数校验失败 |
|
||||
| UnauthorizedError | 401 | 未认证 |
|
||||
| ForbiddenError | 403 | 无权限 |
|
||||
| ConflictError | 409 | 资源冲突 |
|
||||
| ExternalServiceError | 502 | 外部服务错误 |
|
||||
| ScraperError | 404 | 刮削失败 |
|
||||
| TranscodeError | 500 | 转码失败 |
|
||||
| DownloadClientError | 502 | 下载客户端错误 |
|
||||
| SiteError | 502 | 站点错误 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 配置系统
|
||||
|
||||
> 源文件:`backend/app/config.py`
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
| 分类 | 变量 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| **应用** | APP_NAME | MediaStation | 应用名 |
|
||||
| | APP_PORT | 3001 | 端口 |
|
||||
| | APP_DEBUG | false | 调试模式 |
|
||||
| | APP_SECRET_KEY | AUTO_GENERATE | JWT 密钥(自动生成警告) |
|
||||
| | DATA_DIR | ./data | 数据目录 |
|
||||
| | SERVER_URL | "" | 服务器地址(外部播放器用) |
|
||||
| **数据库** | DATABASE_URL | "" | 留空用 SQLite |
|
||||
| **TMDb** | TMDB_API_KEY | "" | TMDb API Key |
|
||||
| | TMDB_LANGUAGE | zh-CN | TMDb 语言 |
|
||||
| | TMDB_BASE_URL | https://api.themoviedb.org/3 | TMDb API 地址 |
|
||||
| **豆瓣** | DOUBAN_COOKIE | "" | 豆瓣 Cookie |
|
||||
| **Bangumi** | BANGUMI_TOKEN | "" | Bangumi Token |
|
||||
| **qBittorrent** | QB_HOST | "" | qBittorrent 地址 |
|
||||
| | QB_USERNAME | admin | 用户名 |
|
||||
| | QB_PASSWORD | adminadmin | 密码 |
|
||||
| **Transmission** | TR_HOST | "" | Transmission 地址 |
|
||||
| | TR_USERNAME | "" | 用户名 |
|
||||
| | TR_PASSWORD | "" | 密码 |
|
||||
| **Telegram** | TELEGRAM_BOT_TOKEN | "" | Bot Token |
|
||||
| | TELEGRAM_CHAT_ID | "" | Chat ID |
|
||||
| **微信** | WECHAT_SENDKEY | "" | Server酱 SendKey |
|
||||
| **Bark** | BARK_SERVER | "" | Bark 服务器 |
|
||||
| | BARK_KEY | "" | Bark Key |
|
||||
| **AI** | OPENAI_API_KEY | "" | OpenAI API Key |
|
||||
| | OPENAI_BASE_URL | https://api.openai.com/v1 | API 地址 |
|
||||
| | OPENAI_MODEL | gpt-4o-mini | 模型 |
|
||||
| **FFmpeg** | FFMPEG_PATH | ffmpeg | FFmpeg 路径 |
|
||||
| | FFPROBE_PATH | ffprobe | FFprobe 路径 |
|
||||
| | HW_ACCEL | auto | 硬件加速 (auto/qsv/vaapi/nvenc/videotoolbox/none) |
|
||||
| | MAX_TRANSCODE_JOBS | 2 | 最大并发转码 |
|
||||
| | TRANSCODE_ENABLED | false | 默认关闭转码 |
|
||||
| **媒体目录** | MOVIES_DIR | "" | 电影目录 |
|
||||
| | TV_DIR | "" | 剧集目录 |
|
||||
| | ANIME_DIR | "" | 动漫目录 |
|
||||
| **JWT** | JWT_ACCESS_EXPIRE_MINUTES | 60 | Access Token 有效期 |
|
||||
| | JWT_REFRESH_EXPIRE_DAYS | 30 | Refresh Token 有效期 |
|
||||
| **安全** | VERIFY_CLIENT_SSL | true | 下载客户端 SSL 校验 |
|
||||
| **CORS** | CORS_ORIGINS | "" | 逗号分隔的允许源 |
|
||||
|
||||
### 数据库存储配置(settings 表)
|
||||
|
||||
整理/刮削相关配置通过 `SettingsKV` 表存储,通过 `/api/settings` 端点管理。
|
||||
|
||||
### API 配置(api_configs 表)
|
||||
|
||||
各数据源 API Key 通过 `ApiConfig` 表存储,支持加密,通过 `/api/api-config` 端点管理。
|
||||
|
||||
---
|
||||
|
||||
## 8. 技术栈对照表
|
||||
|
||||
| 层次 | 原版 (Python) | 目标 (Go) |
|
||||
|------|--------------|-----------|
|
||||
| **Web 框架** | FastAPI | Gin |
|
||||
| **ORM** | SQLAlchemy (async) | GORM |
|
||||
| **数据库** | SQLite / PostgreSQL | SQLite / PostgreSQL |
|
||||
| **认证** | python-jose (JWT) + passlib | golang-jwt + bcrypt |
|
||||
| **任务调度** | APScheduler | robfig/cron 或类似 |
|
||||
| **SSE** | sse-starlette | 原生实现 |
|
||||
| **HTTP 客户端** | httpx | net/http |
|
||||
| **模板引擎** | 无(SPA) | 无(SPA) |
|
||||
| **前端** | Vue 3 + Pinia + Vue Router | React + Zustand + React Router |
|
||||
| **UI 框架** | 未明确(推测自定义/Vuetify) | MUI + Tailwind CSS |
|
||||
| **构建工具** | Vite | Vite |
|
||||
| **加密** | cryptography (Fernet) | crypto/aes |
|
||||
| **视频处理** | FFmpeg (subprocess) | FFmpeg (exec) |
|
||||
| **容器化** | Docker + Helm | Docker + Helm |
|
||||
| **反向代理** | Nginx | Nginx |
|
||||
|
||||
---
|
||||
|
||||
## 附录:API 端点总数统计
|
||||
|
||||
| 模块 | 端点数量 |
|
||||
|------|---------|
|
||||
| 用户与认证 | 19 |
|
||||
| 媒体库 | 31 |
|
||||
| 播放 | 11 |
|
||||
| 下载 | 12 |
|
||||
| 订阅与站点 | 20 |
|
||||
| 系统 | 15 |
|
||||
| 管理后台 | ~20 |
|
||||
| 统计 | 8 |
|
||||
| 播放列表 | 8 |
|
||||
| STRM | 6 |
|
||||
| DLNA | 3 |
|
||||
| 授权管理 | 13 |
|
||||
| Emby 兼容层 | ~50 |
|
||||
| 发现/探索 | 3 |
|
||||
| **总计** | **~220** |
|
||||
|
||||
---
|
||||
|
||||
> **文档版本**: v1.0 | **分析范围**: `MediaStation-py` 全量源代码
|
||||
@@ -0,0 +1,834 @@
|
||||
# MediaStationGo 重构架构设计方案
|
||||
|
||||
> **版本**: v1.0 | **日期**: 2026-02-04 | **作者**: Architect (Bob)
|
||||
>
|
||||
> 本文档基于原版 MediaStation(~220 API)与当前 Go 版实现(~50+ API)的差距分析,
|
||||
> 设计完整的重构架构方案,涵盖数据模型、文件结构、依赖、任务分解和跨模块约定。
|
||||
|
||||
---
|
||||
|
||||
## 一、差距总览
|
||||
|
||||
| 维度 | 原版 (Python) | 当前 Go 版 | 差距 | 优先级 |
|
||||
|------|---------------|-----------|------|--------|
|
||||
| **API 端点总数** | ~220 | ~50+ | **~170 个缺失** | - |
|
||||
| **认证** | access_token + refresh_token | 单一 JWT(24h) | 缺 Token 刷新 + 细粒度权限 | P0 |
|
||||
| **用户系统** | 19 项细粒度权限 | admin/user 两角色 | 缺完整 RBAC | P0 |
|
||||
| **Emby 兼容层** | ~50 端点 | 无 | **完全缺失** | P0 |
|
||||
| **站点管理** | 6 种 PT 站类型 + 聚合搜索 | 仅 RSS 订阅 | 完全缺失站点抽象层 | P0 |
|
||||
| **通知渠道** | Telegram/微信/Bark/Webhook/Email | 无 | **完全缺失** | P0 |
|
||||
| **下载客户端** | qBittorrent + Transmission + Aria2 | 仅 qBittorrent | 缺 2 个适配器 | P0 |
|
||||
| **统计端点** | 8 个 | 1 个 (/api/stats) | 缺趋势/热门/监控等 | P1 |
|
||||
| **批量操作** | 8 个 | 无 | **完全缺失** | P1 |
|
||||
| **文件管理器** | 浏览/移动/复制/删除/重命名 | 无 | **完全缺失** | P1 |
|
||||
| **API 配置管理** | 9 端点 + 加密存储 | 散落在 Config/Setting | 需统一 ApiConfig 表 | P1 |
|
||||
| **发现页** | 12 个推荐区块 | 2 个 (trending + popular) | 缺 10 个区块 | P1 |
|
||||
| **DLNA 投屏** | stub (3 端点) | 无 | 可延后 | P2 |
|
||||
| **授权/Plus** | 13 端点 | 无 | 可延后 | P2 |
|
||||
|
||||
---
|
||||
|
||||
## 二、新增/修改的数据模型
|
||||
|
||||
### 2.1 新增模型定义
|
||||
|
||||
```go
|
||||
// ═══════════════════════════════════════════
|
||||
// 1. 用户权限模型 (替代 admin/user 二元角色)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// UserPermission stores 19 fine-grained permission flags per user.
|
||||
// Admin users implicitly have all permissions; Plus tier users too.
|
||||
type UserPermission struct {
|
||||
ID string `gorm:"primaryKey;size:36)" json:"id"`
|
||||
UserID string `gorm:"uniqueIndex;size:36;not null" json:"user_id"`
|
||||
|
||||
// Default-on permissions (granted to new users)
|
||||
CanViewDashboard bool `gorm:"default:true" json:"can_view_dashboard"`
|
||||
CanPlayMedia bool `gorm:"default:true" json:"can_play_media"`
|
||||
CanCast bool `gorm:"default:true" json:"can_cast"`
|
||||
CanExternalPlayer bool `gorm:"default:true" json:"can_external_player"`
|
||||
CanFavorite bool `gorm:"default:true" json:"can_favorite"`
|
||||
CanViewHistory bool `gorm:"default:true" json:"can_view_history"`
|
||||
|
||||
// Restricted permissions (admin-only by default)
|
||||
CanEditMedia bool `gorm:"default:false" json:"can_edit_media"`
|
||||
CanRescrape bool `gorm:"default:false" json:"can_rescrape"`
|
||||
CanUseAI bool `gorm:"default:false" json:"can_use_ai"`
|
||||
CanCaptureFrames bool `gorm:"default:false" json:"can_capture_frames"`
|
||||
CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"`
|
||||
CanViewDiscover bool `gorm:"default:false" json:"can_view_discover"`
|
||||
CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"`
|
||||
CanManageSites bool `gorm:"default:false" json:"can_manage_sites"`
|
||||
CanUseAIAssistant bool `gorm:"default:false" json:"can_use_ai_assistant"`
|
||||
CanManageUsers bool `gorm:"default:false" json:"can_manage_users"`
|
||||
CanManageFiles bool `gorm:"default:false" json:"can_manage_files"`
|
||||
CanManageStrm bool `gorm:"default:false" json:"can_manage_strm"`
|
||||
CanAccessSettings bool `gorm:"default:false" json:"can_access_settings"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ═══════════════════════════════════════════
|
||||
// 2. Refresh Token 模型
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// RefreshToken stores long-lived refresh tokens for token rotation.
|
||||
type RefreshToken struct {
|
||||
ID string `gorm:"primaryKey;size:36)" json:"id"`
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Revoked bool `gorm:"default:false" json:"revoked"`
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ═══════════════════════════════════════════
|
||||
// 3. 下载客户端模型 (多客户端支持)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// DownloadClient represents a configured download client (qBittorrent / Transmission / Aria2).
|
||||
type DownloadClient struct {
|
||||
Base
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Type string `gorm:"size:32;not null" json:"type"` // qbittorrent / transmission / aria2
|
||||
Host string `gorm:"size:512;not null" json:"host"` // http://host:port
|
||||
Port int `json:"port"` // 0 = included in host
|
||||
Username string `gorm:"size:255" json:"username,omitempty"`
|
||||
Password string `gorm:"size:255" json:"-"` // stored encrypted or plain
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Category string `gorm:"size:255" json:"category,omitempty"` // default save category
|
||||
}
|
||||
|
||||
// DownloadTask — MODIFY existing: add ClientID, InfoHash, and more fields.
|
||||
// New fields to ADD to existing model:
|
||||
// ClientID string `gorm:"size:36" json:"client_id"` // FK -> DownloadClient.ID
|
||||
// InfoHash string `gorm:"size:64;index" json:"info_hash,omitempty"`
|
||||
// TotalSize int64 `json:"total_size,omitempty"`
|
||||
// Progress float32 `json:"progress"` // 0.0-1.0
|
||||
// SpeedDown int64 `json:"speed_down,omitempty"` // bytes/sec
|
||||
// SpeedUp int64 `json:"speed_up,omitempty"` // bytes/sec
|
||||
// Message string `gorm:"size:512" json:"message,omitempty"`
|
||||
```
|
||||
|
||||
```go
|
||||
// ═══════════════════════════════════════════
|
||||
// 4. 站点管理模型 (PT 站 / RSS)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// Site represents a PT site or custom RSS source configuration.
|
||||
type Site struct {
|
||||
Base
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
BaseURL string `gorm:"size:1024" json:"base_url"`
|
||||
SiteType string `gorm:"size:32;not null" json:"site_type"` // nexusphp / gazelle / unit3d / mteam / discuz / custom_rss
|
||||
AuthType string `gorm:"size:16;not null" json:"auth_type"` // cookie / api_key / auth_header
|
||||
Cookie string `gorm:"type:text" json:"-"` // encrypted at rest
|
||||
APIKey string `gorm:"size:512" json:"-"` // encrypted at rest
|
||||
AuthHeader string `gorm:"size:1024" json:"-"` // encrypted at rest
|
||||
UserAgent string `gorm:"size:512" json:"user_agent,omitempty"`
|
||||
RSSURL string `gorm:"size:2048" json:"rss_url,omitempty"`
|
||||
TimeoutSec int `gorm:"default:30" json:"timeout_sec"`
|
||||
Priority int `gorm:"default:0" json:"priority"`
|
||||
UseProxy bool `gorm:"default:false" json:"use_proxy"`
|
||||
RateLimit int `gorm:"default:0" json:"rate_limit"` // 0 = unlimited
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
|
||||
// Runtime status (not persisted to DB on every request, updated by poller)
|
||||
LoginStatus *string `gorm:"size:32" json:"login_status,omitempty"` // ok / failed / untested
|
||||
UploadBytes int64 `json:"upload_bytes,omitempty"`
|
||||
DownloadBytes int64 `json:"download_bytes,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ═══════════════════════════════════════════
|
||||
// 5. 通知渠道模型
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// NotifyChannel represents a notification delivery channel.
|
||||
type NotifyChannel struct {
|
||||
Base
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
ChannelType string `gorm:"size:32;not null" json:"channel_type"` // telegram / wechat_bark / webhook / email
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
|
||||
// Type-specific config stored as JSON blob
|
||||
// Telegram: { "bot_token": "...", "chat_id": "..." }
|
||||
// WeChat: { "sendkey": "..." }
|
||||
// Bark: { "server": "...", "key": "..." }
|
||||
// Webhook: { "url": "...", "method": "POST", "headers": {} }
|
||||
// Email: { "smtp_host", "smtp_port", "username", "password", "to_address", "from_name" }
|
||||
Config string `gorm:"type:text" json:"config,omitempty"`
|
||||
EncryptedConfig string `gorm:"type:text" json:"-"` // encrypted sensitive fields
|
||||
|
||||
// Which events trigger this channel (comma-separated or JSON array)
|
||||
Events string `gorm:"type:text" json:"events"` // subscription_hit, download_complete, scrape_failed, system_alert
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ═══════════════════════════════════════════
|
||||
// 6. API 配置管理模型 (统一 Provider Key 管理)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// ApiConfig stores external API credentials with optional encryption.
|
||||
type ApiConfig struct {
|
||||
ID string `gorm:"primaryKey;size:36)" json:"id"`
|
||||
Provider string `gorm:"size:64;uniqueIndex;not null" json:"provider"` // tmdb / douban / bangumi / thetvdb / fanart / openai / deepseek / siliconflow / adult
|
||||
APIKey string `gorm:"size:512" json:"-"` // encrypted
|
||||
BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
|
||||
Extra string `gorm:"type:text" json:"extra,omitempty"` // provider-specific extra config (JSON)
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Description string `gorm:"size:255" json:"description,omitempty"`
|
||||
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
||||
TestResult string `gorm:"size:32" json:"test_result,omitempty"` // success / failure / untested
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ═══════════════════════════════════════════
|
||||
// 7. STRM 文件模型 (外部存储支持)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// STRMRecord maps a Media item to an external storage URL.
|
||||
// When Media.Path is a .strm file, its content is read as the real URL.
|
||||
type STRMRecord struct {
|
||||
Base
|
||||
MediaID string `gorm:"uniqueIndex;size:36;not null" json:"media_id"`
|
||||
URL string `gorm:"size:2048;not null" json:"url"` // actual remote URL
|
||||
Protocol string `gorm:"size:32" json:"protocol"` // webdav / alist / s3 / http / https
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ═══════════════════════════════════════════
|
||||
// 8. 增强订阅模型 (扩展现有 Subscription)
|
||||
// ═══════════════════════════════════════════
|
||||
// Add to existing Subscription:
|
||||
// TMDbID int `json:"tmdb_id,omitempty"` // link to scraped media
|
||||
// MediaType string `gorm:"size:32" json:"media_type,omitempty"` // movie / tv / anime
|
||||
// Year int `json:"year,omitempty"`
|
||||
// QualityFilter []byte `json:"quality_filter,omitempty"` // ordered priority list (JSON)
|
||||
// MinSizeMB int `json:"min_size_mb,omitempty"`
|
||||
// MaxSizeMB int `json:"max_size_mb,omitempty"`
|
||||
// ExcludeKeys string `gorm:"size:1024" json:"exclude_keywords,omitempty"`
|
||||
// IncludeKeys string `gorm:"size:1024" json:"include_keywords,omitempty"`
|
||||
// TotalDownloaded int `json:"total_downloaded,omitempty"`
|
||||
// Status string `gorm:"size:32;default:active" json:"status"` // active / paused / disabled
|
||||
```
|
||||
|
||||
```go
|
||||
// ═══════════════════════════════════════════
|
||||
// 9. 字幕模型 (独立实体,从 Media 中分离)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// SubtitleTrack represents a subtitle file associated with media.
|
||||
type SubtitleTrack struct {
|
||||
Base
|
||||
MediaID string `gorm:"index;size:36;not null" json:"media_id"`
|
||||
Language string `gorm:"size:16" json:"language"` // zh / en / und
|
||||
LanguageName string `gorm:"size:128" json:"language_name,omitempty"`
|
||||
Path string `gorm:"size:1024;not null" json:"path"`
|
||||
Source string `gorm:"size:32" json:"source"` // external / embedded / uploaded
|
||||
Codec string `gorm:"size:16" json:"codec"` // srt / ass / vtt / ssa
|
||||
IsInternal bool `gorm:"default:false" json:"is_internal"` // embedded in container
|
||||
StreamIdx int `json:"stream_idx,omitempty"` // for embedded subs
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 需修改的现有模型
|
||||
|
||||
| 模型 | 修改内容 |
|
||||
|------|----------|
|
||||
| **User** | 新增字段:`Tier` (free/plus), `Nickname`, `IsActive`, `AvatarURL`(保留), `LastLoginAt`(保留) |
|
||||
| **Media** | 新增字段:`DoubanID`, `FileHash`(SHA256), `IsDuplicate`, `DuplicateOfID`, `STRMURL`, `Resolution`, `AudioChannels`, `HdrFormat`, `FrameRate`, `ColorSpace`, `BitDepth`, `Genres`(JSON) |
|
||||
| **Library** | 新增字段:`ScanIntervalMin`, `MetadataLanguage`, `AdultContent`, `PreferNFO`, `EnableWatch`, `MinFileSizeMB` |
|
||||
| **Playlist** | 新增字段:`Description`, `CoverURL` |
|
||||
| **PlaybackHistory** | 新增字段:`DeviceType`, `IPAddress`, `PlayedAt`(保留 WatchedAt) |
|
||||
|
||||
---
|
||||
|
||||
## 三、新增/修改的文件清单(完整路径)
|
||||
|
||||
### 3.1 后端新增文件
|
||||
|
||||
```
|
||||
internal/
|
||||
├── model/
|
||||
│ ├── permission.go # UserPermission model
|
||||
│ ├── refresh_token.go # RefreshToken model
|
||||
│ ├── download_client.go # DownloadClient model
|
||||
│ ├── site.go # Site model (PT sites)
|
||||
│ ├── notify_channel.go # NotifyChannel model
|
||||
│ ├── api_config.go # ApiConfig model
|
||||
│ ├── strm.go # STRMRecord model
|
||||
│ └── subtitle_track.go # SubtitleTrack model
|
||||
│
|
||||
├── repository/
|
||||
│ ├── permission_repo.go # UserPermission CRUD
|
||||
│ ├── refresh_token_repo.go # RefreshToken CRUD
|
||||
│ ├── download_client_repo.go # DownloadClient CRUD
|
||||
│ ├── site_repo.go # Site CRUD
|
||||
│ ├── notify_channel_repo.go # NotifyChannel CRUD
|
||||
│ ├── api_config_repo.go # ApiConfig CRUD
|
||||
│ ├── strm_repo.go # STRMRecord CRUD
|
||||
│ └── subtitle_track_repo.go # SubtitleTrack CRUD
|
||||
│
|
||||
├── middleware/
|
||||
│ ├── permission.go # RequirePermission(permissionKey) middleware
|
||||
│ └── emby_auth.go # Emby auth (X-Emby-Token / Bearer / username+password)
|
||||
│
|
||||
├── service/
|
||||
│ ├── permission_svc.go # Permission business logic (defaults, checks, grant/revoke)
|
||||
│ ├── token_svc.go # JWT pair issuance, refresh rotation, revocation
|
||||
│ ├── download_adapter.go # DownloadClient adapter interface + registry
|
||||
│ ├── qbittorrent_adp.go # qBittorrent adapter (refactor from qbittorrent.go)
|
||||
│ ├── transmission_adp.go # Transmission RPC adapter [NEW]
|
||||
│ ├── aria2_adp.go # Aria2 JSON-RPC adapter [NEW]
|
||||
│ ├── download_manager_svc.go # Multi-client orchestration (dispatch by client_type)
|
||||
│ ├── site_svc.go # Site CRUD + test connection + browse resources
|
||||
│ ├── site_adapter.go # SiteAdapter interface + NexusPHP/Gazelle/UNIT3D/MTeam/Discuz adapters
|
||||
│ ├── site_search_svc.go # Cross-site aggregated search
|
||||
│ ├── notify_svc.go # Notification dispatch engine (event → channels)
|
||||
│ ├── notify_telegram.go # Telegram sender
|
||||
│ ├── notify_wechat.go # Server酱 (WeChat) sender
|
||||
│ ├── notify_bark.go # Bark sender
|
||||
│ ├── notify_webhook.go # Webhook sender
|
||||
│ ├── notify_email.go # Email (SMTP) sender
|
||||
│ ├── api_config_svc.go # ApiConfig CRUD + encryption + test connection
|
||||
│ ├── crypto_svc.go # AES-128-CBC encrypt/decrypt for sensitive data
|
||||
│ ├── strm_svc.go # STRM file management + protocol whitelist
|
||||
│ ├── emby_handler_svc.go # Emby API handler (~50 endpoints)
|
||||
│ ├── douban_scraper.go # Douban metadata provider [NEW]
|
||||
│ ├── discover_feed_svc.go # Multi-source discovery feed (12 sections)
|
||||
│ ├── batch_svc.go # Batch operations (scan/scrape/delete/move/favorite/watched/rename/ai-rename)
|
||||
│ ├── filemanager_svc.go # File browser + move/copy/delete/mkdir/rename
|
||||
│ ├── duplicate_svc.go # File hash-based duplicate detection
|
||||
│ ├── stats_enhanced_svc.go # Trend/top-content/top-users/monitor/user-stats
|
||||
│ ├── sse_hub.go # SSE (Server-Sent Events) event stream hub
|
||||
│ ├── external_player_svc.go # External player protocol URLs (8 players)
|
||||
│ ├── thumbnail_svc.go # FFmpeg video frame capture (thumbnail)
|
||||
│ ├── scheduler_svc.go # Cron-based task scheduler (robfig/cron)
|
||||
│ └── backup_svc.go # System backup/restore
|
||||
│
|
||||
├── handler/
|
||||
│ ├── permission_handler.go # GET/PUT user permissions, POST reset
|
||||
│ ├── refresh_handler.go # POST /api/auth/refresh
|
||||
│ ├── download_client_handler.go # CRUD + test for download clients
|
||||
│ ├── site_handler.go # Site CRUD + test + browse + userdata
|
||||
│ ├── site_search_handler.go # GET /api/search/sites (cross-site search)
|
||||
│ ├── notify_handler.go # NotifyChannel CRUD + test
|
||||
│ ├── api_config_handler.go # ApiConfig CRUD + test + providers list
|
||||
│ ├── strm_handler.go # STRM config + media-level STRM URL management
|
||||
│ ├── emby_router.go # All Emby-compatible routes (~50 routes)
|
||||
│ ├── douban_search_handler.go # GET /api/search/douban
|
||||
│ ├── discover_feed_handler.go # GET /api/discover/feed, GET /api/discover/sections
|
||||
│ ├── batch_handler.go # 8 batch operation endpoints
|
||||
│ ├── filemanager_handler.go # File browser + operations
|
||||
│ ├── duplicate_handler.go # Hash compute + scan + list + unmark
|
||||
│ ├── stats_enhanced_handler.go # 7 new stats endpoints
|
||||
│ ├── sse_handler.go # GET /api/system/events (SSE stream)
|
||||
│ ├── external_player_handler.go# External URLs + protocols
|
||||
│ ├── thumbnail_handler.go # GET /api/media/:id/thumbnail
|
||||
│ ├── subtitle_enhanced_handler.go # Upload/scan/extract/manage subtitles
|
||||
│ ├── scheduler_handler.go # Scheduler management
|
||||
│ └── backup_handler.go # Backup/restore
|
||||
│
|
||||
└── emby_types.go # Emby-specific DTOs (User, Item, Session, etc.)
|
||||
```
|
||||
|
||||
### 3.2 后端需修改的文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `model/model.go` | 添加新模型引用到 AllModels();修改 User/Media/Library/Playlist/PlaybackHistory 结构体 |
|
||||
| `repository/repository.go` | Container 添加新 Repository 字段 |
|
||||
| `middleware/middleware.go` | 添加 RequirePermission 构造函数 |
|
||||
| `service/service.go` | Container 添加新 Service 字段;Boot() 启动新后台服务 |
|
||||
| `service/auth.go` | 支持 Tier 字段;签发 access_token + refresh_token 对;集成权限检查 |
|
||||
| `handler/handler.go` | Register() 注册所有新路由分组 |
|
||||
| `config/config.go` | 新增通知配置段、DLNA 配置段、调度器配置段 |
|
||||
| `cmd/server/main.go` | 初始化新服务 |
|
||||
|
||||
### 3.3 前端新增文件
|
||||
|
||||
```
|
||||
web/src/
|
||||
├── pages/
|
||||
│ ├── SitesPage.tsx # 站点管理页面
|
||||
│ ├── SiteSearchPage.tsx # 跨站资源搜索页面
|
||||
│ ├── FileManagerPage.tsx # 文件管理器页面
|
||||
│ ├── SettingsPage.tsx # 系统设置页面(多 Tab)
|
||||
│ │ # 内含子组件:
|
||||
│ │ ├── components/settings/
|
||||
│ │ │ ├── GeneralTab.tsx # 通用设置 Tab
|
||||
│ │ │ ├── AccountTab.tsx # 账户设置 Tab
|
||||
│ │ │ ├── UsersTab.tsx # 用户管理 Tab
|
||||
│ │ │ ├── LibrariesTab.tsx # 媒体库设置 Tab
|
||||
│ │ │ ├── ScrapeOrganizeTab.tsx# 整理与刮削设置 Tab
|
||||
│ │ │ ├── DownloadTab.tsx # 下载客户端设置 Tab
|
||||
│ │ │ ├── NotifyTab.tsx # 通知渠道设置 Tab
|
||||
│ │ │ ├── SchedulerTab.tsx # 定时任务设置 Tab
|
||||
│ │ │ ├── SystemTab.tsx # 系统设置 Tab
|
||||
│ │ │ ├── ApiConfigTab.tsx # API 配置 Tab
|
||||
│ │ │ ├── LicenseTab.tsx # 授权管理 Tab
|
||||
│ │ │ └── AdultTab.tsx # Adult Provider 设置 Tab
|
||||
│ │ ├── ConfigGroup.tsx # 配置表单通用组件
|
||||
│ │ └── ConfigRow.tsx # 配置行组件
|
||||
│ ├── StatsEnhancedPage.tsx # 增强统计仪表盘
|
||||
│ ├── StrmPage.tsx # STRM 文件管理页面
|
||||
│ ├── HistoryPage.tsx # 观看历史页面
|
||||
│ ├── DlnaPage.tsx # DLNA 投屏页面
|
||||
│ ├── AiAssistantPage.tsx # AI 助手对话页面
|
||||
│ ├── PosterWallPage.tsx # 海报墙视图
|
||||
│ └── LicensePage.tsx # 授权管理页面
|
||||
│
|
||||
├── api/
|
||||
│ ├── permission.ts # 权限 API (get/update/reset)
|
||||
│ ├── refresh.ts # Token 刷新 API
|
||||
│ ├── downloadClient.ts # 下载客户端 API
|
||||
│ ├── site.ts # 站点 API
|
||||
│ ├── siteSearch.ts # 跨站搜索 API
|
||||
│ ├── notify.ts # 通知渠道 API
|
||||
│ ├── apiConfig.ts # API 配置管理 API
|
||||
│ ├── strm.ts # STRM API
|
||||
│ ├── douban.ts # 豆瓣搜索 API
|
||||
│ ├── discoverFeed.ts # 发现聚合 API (多源 feed)
|
||||
│ ├── batch.ts # 批量操作 API
|
||||
│ ├── filemanager.ts # 文件管理器 API
|
||||
│ ├── duplicate.ts # 重复检测 API
|
||||
│ ├── statsEnhanced.ts # 增强统计 API
|
||||
│ ├── sse.ts # SSE 连接工具
|
||||
│ ├── externalPlayer.ts # 外部播放器 API
|
||||
│ ├── thumbnail.ts # 截图 API
|
||||
│ ├── subtitleEnhanced.ts # 增强字幕 API (上传/提取/删除)
|
||||
│ ├── scheduler.ts # 定时任务 API
|
||||
│ ├── backup.ts # 备份/恢复 API
|
||||
│ ├── license.ts # 授权 API
|
||||
│ └── dlna.ts # DLNA API
|
||||
│
|
||||
├── stores/
|
||||
│ ├── permissions.ts # 权限状态 store (Zustand)
|
||||
│ ├── settings.ts # 系统设置 store (Zustand)
|
||||
│ ├── notifications.ts # 通知消息 store (Zustand)
|
||||
│ └── sse.ts # SSE 连接 store (Zustand)
|
||||
│
|
||||
├── hooks/
|
||||
│ ├── usePermission.ts # usePermission(key) hook
|
||||
│ ├── useSSE.ts # SSE 事件流 hook
|
||||
│ └── useExternalPlayer.ts # 外部播放器协议生成 hook
|
||||
│
|
||||
├── components/
|
||||
│ ├── settings/ # 设置页子组件目录
|
||||
│ ├── FileTree.tsx # 文件树组件
|
||||
│ ├── FileTreeNode.tsx # 文件树节点组件
|
||||
│ ├── PermissionGuard.tsx # 权限守卫组件 (<PermissionGuard permission="can_play_media">)
|
||||
│ ├── DiscoverSection.tsx # 发现页区块组件
|
||||
│ ├── SiteCard.tsx # 站点卡片组件
|
||||
│ ├── DownloadClientCard.tsx # 下载客户端卡片组件
|
||||
│ ├── NotifyChannelCard.tsx # 通知渠道卡片组件
|
||||
│ ├── ApiConfigCard.tsx # API 配置卡片组件
|
||||
│ ├── BatchOperationBar.tsx # 批量操作栏组件
|
||||
│ ├── PlayerProtocolList.tsx # 播放器协议列表组件
|
||||
│ ├── StatsChart.tsx # 统计图表组件
|
||||
│ └── AppEmpty.tsx # 空状态占位组件
|
||||
│
|
||||
└── types/
|
||||
└── index.ts # 扩展: 添加新类型定义
|
||||
```
|
||||
|
||||
### 3.4 前端需修改的文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `App.tsx` | 新增 ~15 个路由 (Sites/FileManager/Settings/StatsEnhanced/Strm/History/DLNA/AiAssistant/PosterWall/License);添加 `<PermissionGuard>` 路由级守卫 |
|
||||
| `stores/auth.ts` | 新增 permissions 对象、tokenRefresh() 方法、tier 字段 |
|
||||
| `components/RequireAuth.tsx` | 集成权限检查逻辑 |
|
||||
| `components/Layout.tsx` | 侧边栏新增菜单项(站点管理/文件管理/STRM/DLNA/统计/设置) |
|
||||
| `types/index.ts` | 新增所有新模型的 TypeScript 类型 |
|
||||
|
||||
---
|
||||
|
||||
## 四、依赖包列表
|
||||
|
||||
### 4.1 Go 新增依赖
|
||||
|
||||
```
|
||||
# 已有依赖保持不变,新增:
|
||||
|
||||
github.com/robfig/cron/v3 v3.0.1 # 定时任务调度器 (APScheduler 替代)
|
||||
golang.org/x/crypto/v0 latest # crypto/aes (AES-128-CBC 加密)
|
||||
github.com/go-resty/resty/v3 v3.10.0 # HTTP client (Transmission/Aria2/Site 调用)
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 # MIME 类型检测 (字幕格式识别)
|
||||
github.com/disintegration/imaging v4.0.0 # 图片处理 (缩略图截取/缩放)
|
||||
```
|
||||
|
||||
### 4.2 npm 新增依赖
|
||||
|
||||
```
|
||||
# 已有依赖保持不变,新增:
|
||||
|
||||
@mui/icons-material ^5.14.0 # Material Design 图标库(设置页需要大量图标)
|
||||
recharts ^2.5.0 # React 图表库(统计图表)
|
||||
react-virtuoso ^4.6.0 # 虚拟滚动(大列表性能)
|
||||
dayjs ^1.11.10 # 轻量日期库(替代 moment.js)
|
||||
framer-motion ^11.0.0 # 动画库(发现页过渡动画)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、任务分解(按实现顺序)
|
||||
|
||||
### T01: 项目基础设施增强(认证+权限+加密+SSE 核心)
|
||||
|
||||
**优先级**: P0 | **依赖**: 无 | **预估代码量**: ~2000 行
|
||||
|
||||
| 类别 | 文件 |
|
||||
|------|------|
|
||||
| **新模型** | `model/permission.go`, `model/refresh_token.go`, `model/api_config.go` |
|
||||
| **Repository** | `permission_repo.go`, `refresh_token_repo.go`, `api_config_repo.go` |
|
||||
| **Service** | `service/permission_svc.go`, `service/token_svc.go`, `service/crypto_svc.go`, `service/sse_hub.go` |
|
||||
| **Middleware** | `middleware/permission.go` |
|
||||
| **Handler** | `handler/permission_handler.go`, `handler/refresh_handler.go`, `handler/api_config_handler.go` |
|
||||
| **Handler (mod)** | `handler/handler.go` (注册新路由) |
|
||||
| **Service (mod)** | `service/auth.go` (支持 token 对 + tier), `service/service.go` (Container 扩展) |
|
||||
| **Model (mod)** | `model/model.go` (AllModels 扩展, User 字段扩展) |
|
||||
| **前端** | `stores/auth.ts` (permissions + refresh), `stores/sse.ts`, `stores/permissions.ts`, `hooks/usePermission.ts`, `hooks/useSSE.ts`, `components/PermissionGuard.tsx`, `types/index.ts` (扩展), `api/permission.ts`, `api/refresh.ts`, `api/apiConfig.ts` |
|
||||
|
||||
**核心交付物**:
|
||||
- 19 项细粒度权限系统的完整链路(模型→仓库→中间件→服务→Handler→前端守卫)
|
||||
- Access Token (60min) + Refresh Token (30天) 双令牌机制
|
||||
- AES-128-CBC 加密服务(敏感数据加解密,兼容明文迁移)
|
||||
- SSE 事件流 Hub(替代 WebSocket 的备选实时通道)
|
||||
- ApiConfig 表 + 9 个管理端点(统一 API Key 管理 + 加密存储 + 测试连接)
|
||||
|
||||
---
|
||||
|
||||
### T02: 多下载客户端 + 通知渠道 + 定时任务
|
||||
|
||||
**优先级**: P0 | **依赖**: T01(加密服务用于密码加密) | **预估代码量**: ~3000 行
|
||||
|
||||
| 类别 | 文件 |
|
||||
|------|------|
|
||||
| **新模型** | `model/download_client.go` |
|
||||
| **Repository** | `download_client_repo.go`, `notify_channel_repo.go` |
|
||||
| **Service** | `service/download_adapter.go` (接口), `service/qbittorrent_adp.go` (重构), `service/transmission_adp.go`, `service/aria2_adp.go`, `service/download_manager_svc.go`, `service/notify_svc.go`, `service/notify_telegram.go`, `service/notify_wechat.go`, `service/notify_bark.go`, `service/notify_webhook.go`, `service/notify_email.go`, `service/scheduler_svc.go` |
|
||||
| **Handler** | `handler/download_client_handler.go`, `handler/notify_handler.go`, `handler/scheduler_handler.go` |
|
||||
| **Handler (mod)** | `handler/handler.go`, `handler/downloads.go` (改为通过 DownloadManager) |
|
||||
| **Service (mod)** | `service/downloads.go` (重构为多客户端分发), `service/service.go` (Boot 启动 scheduler + notifier) |
|
||||
| **前端** | `api/downloadClient.ts`, `api/notify.ts`, `api/scheduler.ts`, `components/DownloadClientCard.tsx`, `components/NotifyChannelCard.tsx` |
|
||||
|
||||
**核心交付物**:
|
||||
- DownloadClient 适配器模式(Interface + qBittorrent/Transmission/Aria2 三实现)
|
||||
- 5 种通知渠道(Telegram/Server酱/Bark/Webhook/Email)完整发送链路
|
||||
- 事件驱动通知引擎(subscription_hit / download_complete / scrape_failed / system_alert)
|
||||
- 基于 robfig/cron 的定时任务调度器(替代 APScheduler),内置 6 个预配置任务
|
||||
- 下载客户端热插拔(运行时添加/移除/测试连接)
|
||||
|
||||
---
|
||||
|
||||
### T03: 站点管理 + Emby API 兼容层
|
||||
|
||||
**优先级**: P0 | **依赖**: T01(加密服务用于 Cookie/APIKey 存储) | **预估代码量**: ~4000 行
|
||||
|
||||
| 类别 | 文件 |
|
||||
|------|------|
|
||||
| **新模型** | `model/site.go` |
|
||||
| **Repository** | `site_repo.go` |
|
||||
| **Service** | `service/site_svc.go`, `service/site_adapter.go` (接口+6种适配器), `service/site_search_svc.go`, `service/emby_handler_svc.go`, `service/strm_svc.go` |
|
||||
| **Model** | `model/strm.go`, `emby_types.go` |
|
||||
| **Handler** | `handler/site_handler.go`, `handler/site_search_handler.go`, `handler/emby_router.go` (~50 路由), `handler/strm_handler.go` |
|
||||
| **Middleware** | `middleware/emby_auth.go` |
|
||||
| **前端** | `api/site.ts`, `api/siteSearch.ts`, `api/strm.ts`, `pages/SitesPage.tsx`, `pages/SiteSearchPage.tsx`, `pages/StrmPage.tsx`, `components/SiteCard.tsx` |
|
||||
|
||||
**核心交付物**:
|
||||
- 6 种 PT 站点类型适配器(NexusPHP / Gazelle / UNIT3D / MTeam / Discuz / Custom RSS)
|
||||
- 3 种认证方式(Cookie / API Key / Authorization Header),全部加密存储
|
||||
- 跨站聚合搜索(并发搜索多个站点,合并去重排序)
|
||||
- 站点资源浏览器(分页浏览种子列表)
|
||||
- **Emby API 兼容层**(~50 端点):认证 → 系统信息 → 媒体库 → Items → PlaybackInfo → 流代理 → 字幕 → 进度上报
|
||||
- STRM 文件管理(外部存储以"文件"形式入库,WebDAV/Alist/S3/HTTP 协议白名单校验)
|
||||
- Emby 认证中间件(X-Emby-Token / Bearer / Username+Password 三种方式)
|
||||
|
||||
---
|
||||
|
||||
### T04: 增强功能集(发现页+统计+批量操作+文件管理+播放增强+刮削增强)
|
||||
|
||||
**优先级**: P1 | **依赖**: T01, T02, T03 | **预估代码量**: ~3500 行
|
||||
|
||||
| 类别 | 文件 |
|
||||
|------|------|
|
||||
| **Service** | `service/discover_feed_svc.go` (12 区块), `service/stats_enhanced_svc.go`, `service/batch_svc.go`, `service/filemanager_svc.go`, `service/duplicate_svc.go`, `service/douban_scraper.go`, `service/external_player_svc.go`, `service/thumbnail_svc.go`, `service/backup_svc.go` |
|
||||
| **Model** | `model/subtitle_track.go` |
|
||||
| **Repository** | `subtitle_track_repo.go`, `strm_repo.go` |
|
||||
| **Handler** | `handler/discover_feed_handler.go`, `handler/stats_enhanced_handler.go`, `handler/batch_handler.go`, `handler/filemanager_handler.go`, `handler/duplicate_handler.go`, `handler/douban_search_handler.go`, `handler/external_player_handler.go`, `handler/thumbnail_handler.go`, `handler/subtitle_enhanced_handler.go`, `handler/backup_handler.go`, `handler/recycle.go` (扩展) |
|
||||
| **前端 (pages)** | `pages/SettingsPage.tsx` (+12个子Tab组件), `pages/StatsEnhancedPage.tsx`, `pages/FileManagerPage.tsx`, `pages/HistoryPage.tsx`, `pages/PosterWallPage.tsx`, `pages/AiAssistantPage.tsx`, `pages/DlnaPage.tsx`, `pages/LicensePage.tsx` |
|
||||
| **前端 (api)** | `api/douban.ts`, `api/discoverFeed.ts`, `api/batch.ts`, `api/filemanager.ts`, `api/duplicate.ts`, `api/statsEnhanced.ts`, `api/externalPlayer.ts`, `api/thumbnail.ts`, `api/subtitleEnhanced.ts`, `api/backup.ts`, `api/license.ts`, `api/dlna.ts`, `api/sse.ts` |
|
||||
| **前端 (components)** | `components/settings/*` (12个Tab), `components/FileTree.tsx`, `components/FileTreeNode.tsx`, `components/DiscoverSection.tsx`, `components/BatchOperationBar.tsx`, `components/PlayerProtocolList.tsx`, `components/StatsChart.tsx`, `components/AppEmpty.tsx` |
|
||||
| **前端 (stores)** | `stores/settings.ts`, `stores/notifications.ts` |
|
||||
| **前端 (hooks)** | `hooks/useExternalPlayer.ts` |
|
||||
| **前端 (mod)** | `App.tsx` (15个新路由 + 权限守卫), `Layout.tsx` (侧边栏扩展), `types/index.ts` (类型扩展) |
|
||||
|
||||
**核心交付物**:
|
||||
- **增强发现页**(12 个推荐区块:本地最近电影/剧集 + TMDb 4 区块 + 豆瓣 4 区块 + Bangumi 每日)
|
||||
- **豆瓣刮削器**(DoubanProvider,补充中文元数据,Cookie 认证)
|
||||
- **增强统计**(8 端点:概览/播放趋势/热门内容/活跃用户/媒体库统计/系统监控/用户统计/记录播放)
|
||||
- **批量操作**(8 个端点:扫描/刮削/删除/移动/收藏/标记已看/重命名/AI重命名)
|
||||
- **文件管理器**(目录浏览 + 移动/复制/删除/创建文件夹/重命名/重命名预览)
|
||||
- **外部播放器直链**(8 种协议:PotPlayer/VLC/IINA/Infuse/NPlayer/MX/MPV/MPC-HC)
|
||||
- **视频截帧**(FFmpeg thumbnail 提取,用于卡片封面)
|
||||
- **增强字幕管理**(上传/扫描外挂/检测内嵌/提取内嵌/删除)
|
||||
- **文件哈希重复检测**(SHA256 哈希计算 + 重复标记/取消)
|
||||
- **系统备份/恢复**(SQLite 数据库备份)
|
||||
- DLNA stub(设备发现/投屏框架,可后续完善)
|
||||
- 授权/Plus 系统(13 端点框架,可后续接入验证服务器)
|
||||
|
||||
---
|
||||
|
||||
### T05: 集成优化 + 测试 + 文档
|
||||
|
||||
**优先级**: P1 | **依赖**: T01-T04 全部完成 | **预估代码量**: ~1000 行
|
||||
|
||||
| 类别 | 文件 |
|
||||
|------|------|
|
||||
| **配置** | `config/config.go` (新增段: Notify, DLNA, Scheduler, License) |
|
||||
| **入口** | `cmd/server/main.go` (初始化所有新服务) |
|
||||
| **集成** | `service/service.go` (最终 Container + Boot + Close) |
|
||||
| **路由整合** | `handler/handler.go` (最终全部路由注册) |
|
||||
| **文档** | `docs/refactor-migration-guide.md` (迁移指南) |
|
||||
| **测试** | 各 service 对应的 `_test.go` 补充 |
|
||||
|
||||
**核心交付物**:
|
||||
- 所有新服务的启动/关闭生命周期整合
|
||||
- 最终路由注册(目标 ~220 个端点)
|
||||
- 配置文件 Schema 更新(新增 Notify/DLNA/Scheduler/License 段)
|
||||
- 迁移指南文档(从旧版升级的数据变更说明)
|
||||
|
||||
---
|
||||
|
||||
## 六、共享知识(跨文件约定)
|
||||
|
||||
### 6.1 错误码规范
|
||||
|
||||
```go
|
||||
// 应用错误码常量 (package apperr)
|
||||
const (
|
||||
ErrOK = 0
|
||||
ErrInvalidParams = 40001
|
||||
ErrUnauthorized = 40101
|
||||
ErrForbidden = 40301
|
||||
ErrNotFound = 40401
|
||||
ErrConflict = 40901
|
||||
ErrRateLimit = 42901
|
||||
ErrInternal = 50001
|
||||
ErrExternalService = 50201 // 第三方服务不可达
|
||||
ErrScraperFailed = 50401 // 刮削超时/失败
|
||||
ErrTranscodeFailed = 50501 // FFmpeg 转码失败
|
||||
ErrDownloadClient = 50601 // 下载客户端错误
|
||||
ErrSiteAuthFailed = 50701 // 站点认证失败
|
||||
ErrEncryptFailed = 50801 // 加密操作失败
|
||||
)
|
||||
|
||||
// 统一错误响应格式
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"` // 业务错误码
|
||||
Data any `json:"data,omitempty"` // 成功时的 payload
|
||||
Message string `json:"message,omitempty"` // 人可读错误信息
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 API 响应格式约定
|
||||
|
||||
```
|
||||
成功响应 (2xx):
|
||||
{
|
||||
"code": 0,
|
||||
"data": { ... },
|
||||
"message": "ok"
|
||||
}
|
||||
|
||||
分页列表响应:
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": [...],
|
||||
"total": 150,
|
||||
"page": 1,
|
||||
"page_size": 50
|
||||
}
|
||||
}
|
||||
|
||||
错误响应 (non-2xx):
|
||||
{
|
||||
"code": 40101,
|
||||
"data": null,
|
||||
"message": "token expired"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 权限检查约定
|
||||
|
||||
```go
|
||||
// 后端中间件用法
|
||||
r.POST("/media/:id/scrape",
|
||||
middleware.AuthRequired(secret),
|
||||
middleware.RequirePermission("can_rescrape"), // NEW
|
||||
middleware.AdminRequired(), // still works (implies all permissions)
|
||||
scrapeOneHandler(svc),
|
||||
)
|
||||
|
||||
// 权限解析优先级:
|
||||
// 1. role == "admin" → 自动拥有所有权限(跳过 DB 查询)
|
||||
// 2. tier == "plus" → 自动拥有所有权限(跳过 DB 查询)
|
||||
// 3. 普通 user → 查询 user_permissions 表,逐字段判断
|
||||
```
|
||||
|
||||
```tsx
|
||||
// 前端路由守卫用法
|
||||
<Route path="discover" element={
|
||||
<PermissionGuard permission="can_view_discover">
|
||||
<DiscoverPage />
|
||||
</PermissionGuard>
|
||||
} />
|
||||
```
|
||||
|
||||
### 6.4 敏感数据加密约定
|
||||
|
||||
```go
|
||||
// 加密标识前缀
|
||||
const EncryptPrefix = "enc:v1:"
|
||||
|
||||
// 加密流程:
|
||||
// 1. 检查值是否已以 enc:v1: 开头 → 是则跳过(避免双重加密)
|
||||
// 2. 使用 AES-128-CBC 加密,密钥从 APP_SECRET_KEY 派生 (PBKDF2 + SHA256)
|
||||
// 3. 存储为 enc:v1:<base64_ciphertext>
|
||||
|
||||
// 解密流程:
|
||||
// 1. 检查是否以 enc:v1: 开头 → 否则返回原文(明文兼容)
|
||||
// 2. AES-128-CBC 解密
|
||||
// 3. 返回明文
|
||||
|
||||
// 适用字段: Site.Cookie/Site.APIKey/Site.AuthHeader /
|
||||
// DownloadClient.Password / NotifyChannel.EncryptedConfig /
|
||||
// ApiConfig.APIKey / ApiConfig.Extra
|
||||
```
|
||||
|
||||
### 6.5 Emby API 兼容层约定
|
||||
|
||||
```
|
||||
路由前缀: /emby/
|
||||
认证方式 (按优先级):
|
||||
1. X-Emby-Token: <token> (Emby 标准头)
|
||||
2. Authorization: Bearer <token> (标准 JWT)
|
||||
3. query: ?token=<token> (URL 参数,用于<video> src)
|
||||
4. POST body: {Username, Password} (Emby AuthenticateByName)
|
||||
|
||||
Emby Token 与内部 JWT 的映射关系:
|
||||
- Emby 认证成功后,返回 EmbyUserId + AccessToken (即内部 JWT)
|
||||
- 后续请求通过映射表查找对应用户
|
||||
```
|
||||
|
||||
### 6.6 事件系统约定
|
||||
|
||||
```go
|
||||
// 支持两种推送通道:
|
||||
|
||||
// 1. WebSocket (已有) — 用于需要双向通信的场景 (scan progress, transcode status)
|
||||
// Topics: scan | scrape | transcode | download
|
||||
|
||||
// 2. SSE (新增) — 用于服务端单向推送场景 (notification, task progress)
|
||||
// Events: notification | task_progress | alert
|
||||
// 认证: 一次性 OTP ticket (GET /api/system/events/ticket → 10s 有效)
|
||||
// 或 Authorization Bearer (降级方案)
|
||||
|
||||
// 通知事件类型:
|
||||
const (
|
||||
EventSubscriptionHit = "subscription_hit" // 订阅命中新资源
|
||||
EventDownloadComplete = "download_complete" // 下载完成
|
||||
EventScrapeFailed = "scrape_failed" // 刮削失败
|
||||
EventSystemAlert = "system_alert" // 系统告警 (磁盘满/CPU高等)
|
||||
)
|
||||
```
|
||||
|
||||
### 6.7 下载客户端适配器接口
|
||||
|
||||
```go
|
||||
type DownloadAdapter interface {
|
||||
// 生命周期
|
||||
Initialize(ctx context.Context, cfg DownloadClientConfig) error
|
||||
Ping(ctx context.Context) error
|
||||
|
||||
// 任务操作
|
||||
AddTorrent(ctx context.Context, url, savePath string) (string, error) // returns hash/id
|
||||
Pause(ctx context.Context, hash string) error
|
||||
Resume(ctx context.Context, hash string) error
|
||||
Remove(ctx context.Context, hash string, deleteFiles bool) error
|
||||
|
||||
// 状态查询
|
||||
List(ctx context.Context, filter string) ([]TorrentInfo, error)
|
||||
GetGlobalStats(ctx context.Context) (*GlobalStats, error) // Aria2 only
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、任务依赖关系图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
T01[T01: 基础设施<br/>认证+权限+加密+SSE] --> T03
|
||||
T01 --> T02
|
||||
T02[T02: 多下载客户端<br/>+通知+定时任务] --> T04
|
||||
T03[T03: 站点管理<br/>+Emby兼容层] --> T04
|
||||
T04[T04: 增强功能集<br/>发现+统计+批量+文件管理+播放增强] --> T05
|
||||
T05[T05: 集成优化+测试+文档]
|
||||
|
||||
style T01 fill:#e1f5fe
|
||||
style T02 fill:#fff3e0
|
||||
style T03 fill:#fce4ec
|
||||
style T04 fill:#f3e5f5
|
||||
style T05 fill:#e8f5e9
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、预估代码规模汇总
|
||||
|
||||
| 任务 | 新增后端文件 | 新增前端文件 | 修改文件 | 预估行数 |
|
||||
|------|------------|------------|---------|---------|
|
||||
| T01 | ~18 | ~14 | ~8 | ~2000 |
|
||||
| T02 | ~16 | ~8 | ~4 | ~3000 |
|
||||
| T03 | ~14 | ~8 | ~4 | ~4000 |
|
||||
| T04 | ~20 | ~35 | ~5 | ~3500 |
|
||||
| T05 | ~2 | ~2 | ~5 | ~1000 |
|
||||
| **合计** | **~70** | **~67** | **~26** | **~13500** |
|
||||
|
||||
对比当前代码库(Go ~5000 行,前端 ~3000 行),本次重构将使代码总量增长约 **2.5 倍**。
|
||||
|
||||
---
|
||||
|
||||
## 九、实施建议
|
||||
|
||||
### 分期策略
|
||||
1. **Phase 1(必须做)**: T01 + T02 → 核心基础设施和多客户端/通知,解决最大痛点
|
||||
2. **Phase 2(应该做)**: T03 → Emby 兼容层是差异化竞争力(让 Infuse/Kodi 直连)
|
||||
3. **Phase 3(最好做)**: T04 → 功能完整性(对标原版 ~220 API)
|
||||
4. **Phase 4(收尾)**: T05 → 打磨质量
|
||||
|
||||
### 可并行化的独立模块
|
||||
- Emby 兼容层与站点管理可以独立开发(仅共享基础模型/T01)
|
||||
- 通知渠道之间完全独立(每种渠道一个文件)
|
||||
- 下载客户端适配器之间完全独立
|
||||
- 前端各页面可以并行开发
|
||||
|
||||
### 风险控制
|
||||
- **Emby 兼容层复杂度最高**:建议先实现核心 15 个端点(认证+媒体列表+播放+进度),再逐步补全
|
||||
- **PT 站适配器**:每个站点类型的 HTML 解析差异大,建议先实现 NexusPHP(覆盖最广),其他用通用 RSS 替代
|
||||
- **加密迁移**:必须保证旧明文数据的读取兼容(detect prefix 策略)
|
||||
@@ -0,0 +1,12 @@
|
||||
graph TD
|
||||
T01[T01: 基础设施增强 - 认证+权限+加密+SSE核心] -->|依赖: 加密服务| T03
|
||||
T01 -->|依赖: 权限系统| T02
|
||||
T02[T02: 多下载客户端 + 通知渠道 + 定时任务] -->|依赖: 事件引擎/多客户端| T04
|
||||
T03[T03: 站点管理 + Emby API兼容层] -->|依赖: 站点适配器/Emby认证| T04
|
||||
T04[T04: 增强功能集 - 发现页+统计+批量操作+文件管理+播放增强+刮削增强] --> T05[T05: 集成优化 + 测试 + 文档]
|
||||
|
||||
style T01 fill:#e1f5fe,stroke:#0277bd,color:#01579b
|
||||
style T02 fill:#fff3e0,stroke:#e65100,color:#bf360c
|
||||
style T03 fill:#fce4ec,stroke:#c2185b,color:#880e4f
|
||||
style T04 fill:#f3e5f5,stroke:#6a1b9a,color:#4a148c
|
||||
style T05 fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20
|
||||
+37
-31
@@ -1,17 +1,10 @@
|
||||
// Package config loads layered configuration from defaults, config files and
|
||||
// environment variables, mirroring the conventions used by nowen-video.
|
||||
// Package config 加载分层配置:默认值、配置文件和环境变量。
|
||||
//
|
||||
// Priority (low -> high):
|
||||
// 1. Built-in defaults
|
||||
// 2. config.yaml in the working directory (nested format)
|
||||
// 3. config/*.yaml shard files (per-module)
|
||||
// 4. Environment variables prefixed with MEDIASTATION_
|
||||
//
|
||||
// Environment variable example:
|
||||
//
|
||||
// MEDIASTATION_APP_PORT=8080
|
||||
// MEDIASTATION_SECRETS_JWT_SECRET=please-change-me
|
||||
// MEDIASTATION_DATABASE_DB_PATH=/data/mediastation.db
|
||||
// 优先级(低 -> 高):
|
||||
// 1. 内置默认值
|
||||
// 2. 工作目录中的 config.yaml(嵌套格式)
|
||||
// 3. config/*.yaml 分片文件(按模块)
|
||||
// 4. 以 MEDIASTATION_ 为前缀的环境变量
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -25,10 +18,10 @@ import (
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// EnvPrefix is the prefix used for all env-var-driven overrides.
|
||||
// EnvPrefix 是所有环境变量驱动的覆盖使用的前缀。
|
||||
const EnvPrefix = "MEDIASTATION"
|
||||
|
||||
// Config is the root config aggregate.
|
||||
// Config 是根配置聚合。
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
@@ -38,9 +31,18 @@ type Config struct {
|
||||
Media MediaConfig `mapstructure:"media"`
|
||||
Transcoder TranscoderConfig `mapstructure:"transcoder"`
|
||||
AI AIConfig `mapstructure:"ai"`
|
||||
ApiConfig ApiConfigConfig `mapstructure:"api_config"`
|
||||
}
|
||||
|
||||
// TranscoderConfig controls the HLS / ffmpeg backend.
|
||||
// ApiConfigConfig API 配置相关设置。
|
||||
type ApiConfigConfig struct {
|
||||
// AutoEncrypt 是否自动加密敏感字段
|
||||
AutoEncrypt bool `mapstructure:"auto_encrypt"`
|
||||
// DefaultTimeout 默认请求超时(秒)
|
||||
DefaultTimeout int `mapstructure:"default_timeout"`
|
||||
}
|
||||
|
||||
// TranscoderConfig 控制 HLS / ffmpeg 后端。
|
||||
type TranscoderConfig struct {
|
||||
Encoder string `mapstructure:"encoder"` // "" / nvenc / qsv / vaapi
|
||||
Preset string `mapstructure:"preset"`
|
||||
@@ -51,7 +53,7 @@ type TranscoderConfig struct {
|
||||
SegmentSeconds int `mapstructure:"segment_seconds"`
|
||||
}
|
||||
|
||||
// AppConfig holds runtime app parameters.
|
||||
// AppConfig 保存运行时应用参数。
|
||||
type AppConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Debug bool `mapstructure:"debug"`
|
||||
@@ -65,7 +67,7 @@ type AppConfig struct {
|
||||
ServerURL string `mapstructure:"server_url"`
|
||||
}
|
||||
|
||||
// DatabaseConfig configures GORM + SQLite.
|
||||
// DatabaseConfig 配置 GORM + SQLite。
|
||||
type DatabaseConfig struct {
|
||||
DBPath string `mapstructure:"db_path"`
|
||||
WALMode bool `mapstructure:"wal_mode"`
|
||||
@@ -75,7 +77,7 @@ type DatabaseConfig struct {
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
}
|
||||
|
||||
// SecretsConfig holds JWT / 3rd-party API keys (do NOT commit values).
|
||||
// SecretsConfig 保存 JWT / 第三方 API 密钥(不要提交值)。
|
||||
type SecretsConfig struct {
|
||||
JWTSecret string `mapstructure:"jwt_secret"`
|
||||
TMDbAPIKey string `mapstructure:"tmdb_api_key"`
|
||||
@@ -85,9 +87,11 @@ type SecretsConfig struct {
|
||||
TheTVDBAPIKey string `mapstructure:"thetvdb_api_key"`
|
||||
FanartAPIKey string `mapstructure:"fanart_tv_api_key"`
|
||||
DoubanCookie string `mapstructure:"douban_cookie"`
|
||||
// 用于加密的密钥,如果为空则使用 JWTSecret
|
||||
EncryptionKey string `mapstructure:"encryption_key"`
|
||||
}
|
||||
|
||||
// LoggingConfig configures Zap.
|
||||
// LoggingConfig 配置 Zap。
|
||||
type LoggingConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
Format string `mapstructure:"format"`
|
||||
@@ -98,7 +102,7 @@ type LoggingConfig struct {
|
||||
MaxBackups int `mapstructure:"max_backups"`
|
||||
}
|
||||
|
||||
// CacheConfig controls the on-disk transcode/scrape cache.
|
||||
// CacheConfig 控制磁盘转码/刮削缓存。
|
||||
type CacheConfig struct {
|
||||
CacheDir string `mapstructure:"cache_dir"`
|
||||
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
|
||||
@@ -107,14 +111,14 @@ type CacheConfig struct {
|
||||
CleanupIntervalMin int `mapstructure:"cleanup_interval_min"`
|
||||
}
|
||||
|
||||
// MediaConfig holds default library locations (used by the bootstrap library).
|
||||
// MediaConfig 保存默认库位置(用于引导库)。
|
||||
type MediaConfig struct {
|
||||
MoviesDir string `mapstructure:"movies_dir"`
|
||||
TVDir string `mapstructure:"tv_dir"`
|
||||
AnimeDir string `mapstructure:"anime_dir"`
|
||||
}
|
||||
|
||||
// AIConfig configures the optional LLM provider.
|
||||
// AIConfig 配置可选的 LLM 提供者。
|
||||
type AIConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Provider string `mapstructure:"provider"`
|
||||
@@ -125,9 +129,9 @@ type AIConfig struct {
|
||||
MaxConcurrent int `mapstructure:"max_concurrent"`
|
||||
}
|
||||
|
||||
// Load reads configuration from defaults / files / environment.
|
||||
// Load 从默认值 / 文件 / 环境读取配置。
|
||||
//
|
||||
// It always returns a usable Config, even if no files are present.
|
||||
// 即使没有文件也始终返回可用的 Config。
|
||||
func Load() (*Config, error) {
|
||||
v := viper.New()
|
||||
setDefaults(v)
|
||||
@@ -143,7 +147,7 @@ func Load() (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Merge sharded files under ./config/*.yaml.
|
||||
// 合并 ./config/*.yaml 下的分片文件。
|
||||
if entries, err := os.ReadDir("config"); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
|
||||
@@ -215,9 +219,13 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("transcoder.buf_size", "3000k")
|
||||
v.SetDefault("transcoder.max_height", 720)
|
||||
v.SetDefault("transcoder.segment_seconds", 4)
|
||||
|
||||
// API Config 默认设置
|
||||
v.SetDefault("api_config.auto_encrypt", true)
|
||||
v.SetDefault("api_config.default_timeout", 30)
|
||||
}
|
||||
|
||||
// normalize fills derived defaults and self-heals empty critical fields.
|
||||
// normalize 填充派生默认值并自愈空的关键字段。
|
||||
func (c *Config) normalize() error {
|
||||
if c.App.DataDir == "" {
|
||||
c.App.DataDir = "./data"
|
||||
@@ -229,8 +237,7 @@ func (c *Config) normalize() error {
|
||||
c.Cache.CacheDir = filepath.Join(c.App.DataDir, "cache")
|
||||
}
|
||||
if c.Secrets.JWTSecret == "" {
|
||||
// Persist an auto-generated secret to keep sessions stable across
|
||||
// restarts even when the operator forgot to configure one.
|
||||
// 持久化自动生成的密钥以在操作员忘记配置时保持会话稳定。
|
||||
path := filepath.Join(c.App.DataDir, ".jwt_secret")
|
||||
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
|
||||
c.Secrets.JWTSecret = strings.TrimSpace(string(data))
|
||||
@@ -247,8 +254,7 @@ func (c *Config) normalize() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// asConfigFileNotFound is a small helper around errors.As that avoids importing
|
||||
// errors in this short file.
|
||||
// asConfigFileNotFound 是 errors.As 的小辅助函数,避免在这个短文件中导入 errors。
|
||||
func asConfigFileNotFound(err error, target *viper.ConfigFileNotFoundError) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// Package handler — API 配置 HTTP Handler。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// ApiConfigHandler API 配置 HTTP 处理。
|
||||
type ApiConfigHandler struct {
|
||||
svc *service.Container
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewApiConfigHandler 创建 API 配置处理器。
|
||||
func NewApiConfigHandler(svc *service.Container, log *zap.Logger) *ApiConfigHandler {
|
||||
return &ApiConfigHandler{svc: svc, log: log}
|
||||
}
|
||||
|
||||
// ListApiConfigs 获取所有 API 配置。
|
||||
// GET /api/api-config
|
||||
func (h *ApiConfigHandler) ListApiConfigs(c *gin.Context) {
|
||||
configs, err := h.svc.ApiConfig.List(c.Request.Context())
|
||||
if err != nil {
|
||||
h.log.Error("list api configs failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 遮蔽 API Key
|
||||
for i := range configs {
|
||||
if configs[i].APIKey != "" {
|
||||
configs[i].APIKey = h.svc.ApiConfig.MaskAPIKey(configs[i].APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": configs})
|
||||
}
|
||||
|
||||
// ListProviders 获取预定义的提供者列表。
|
||||
// GET /api/api-config/providers/list
|
||||
func (h *ApiConfigHandler) ListProviders(c *gin.Context) {
|
||||
providers := h.svc.ApiConfig.GetProviders()
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": providers})
|
||||
}
|
||||
|
||||
// GetApiConfig 获取指定提供者的配置。
|
||||
// GET /api/api-config/:provider
|
||||
func (h *ApiConfigHandler) GetApiConfig(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.svc.ApiConfig.GetByProvider(c.Request.Context(), provider)
|
||||
if err != nil {
|
||||
if err == service.ErrApiConfigNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 40401, "message": "api config not found", "data": nil})
|
||||
return
|
||||
}
|
||||
h.log.Error("get api config failed", zap.Error(err), zap.String("provider", provider))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 遮蔽 API Key
|
||||
if cfg.APIKey != "" {
|
||||
cfg.APIKey = h.svc.ApiConfig.MaskAPIKey(cfg.APIKey)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": cfg})
|
||||
}
|
||||
|
||||
// GetEffectiveConfig 获取生效的配置(数据库配置优先于配置文件)。
|
||||
// GET /api/api-config/:provider/effective
|
||||
func (h *ApiConfigHandler) GetEffectiveConfig(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.svc.ApiConfig.GetEffectiveConfig(c.Request.Context(), provider)
|
||||
if err != nil {
|
||||
if err == service.ErrApiConfigNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 40401, "message": "api config not found", "data": nil})
|
||||
return
|
||||
}
|
||||
h.log.Error("get effective config failed", zap.Error(err), zap.String("provider", provider))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 遮蔽 API Key
|
||||
if cfg.APIKey != "" {
|
||||
cfg.APIKey = h.svc.ApiConfig.MaskAPIKey(cfg.APIKey)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": cfg})
|
||||
}
|
||||
|
||||
// UpsertApiConfig 创建或更新 API 配置。
|
||||
// POST /api/api-config/:provider
|
||||
func (h *ApiConfigHandler) UpsertApiConfig(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Extra string `json:"extra"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "invalid request", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.svc.ApiConfig.Upsert(c.Request.Context(), provider, req.APIKey, req.BaseURL, req.Extra, req.Enabled)
|
||||
if err != nil {
|
||||
if err == service.ErrInvalidProvider {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "invalid provider", "data": nil})
|
||||
return
|
||||
}
|
||||
h.log.Error("upsert api config failed", zap.Error(err), zap.String("provider", provider))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回遮蔽后的配置
|
||||
cfg.APIKey = h.svc.ApiConfig.MaskAPIKey(cfg.APIKey)
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": cfg})
|
||||
}
|
||||
|
||||
// DeleteApiConfig 删除 API 配置。
|
||||
// DELETE /api/api-config/:provider
|
||||
func (h *ApiConfigHandler) DeleteApiConfig(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.ApiConfig.Delete(c.Request.Context(), provider); err != nil {
|
||||
h.log.Error("delete api config failed", zap.Error(err), zap.String("provider", provider))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": nil})
|
||||
}
|
||||
|
||||
// TestApiConfig 测试 API 连接。
|
||||
// POST /api/api-config/:provider/test
|
||||
func (h *ApiConfigHandler) TestApiConfig(c *gin.Context) {
|
||||
provider := c.Param("provider")
|
||||
if provider == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.ApiConfig.TestConnection(c.Request.Context(), provider)
|
||||
if err != nil {
|
||||
h.log.Debug("test api config failed", zap.Error(err), zap.String("provider", provider))
|
||||
// 不返回错误,只返回测试结果
|
||||
}
|
||||
|
||||
// 更新测试结果
|
||||
_ = h.svc.ApiConfig.UpdateTestResult(c.Request.Context(), provider, result)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": gin.H{
|
||||
"result": result,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -28,20 +28,24 @@ func loginHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
u, token, err := svc.Auth.Login(c.Request.Context(), req.Username, req.Password)
|
||||
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrInvalidCredentials) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrUserInactive) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "user account is inactive"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"user": u,
|
||||
"user": resp.User,
|
||||
"tokens": resp.Tokens,
|
||||
})
|
||||
svc.Audit.Record(c.Request.Context(), u.ID, "auth.login", u.Username, c.ClientIP(), "")
|
||||
svc.Audit.Record(c.Request.Context(), resp.User.ID, "auth.login", resp.User.Username, c.ClientIP(), "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +56,7 @@ func registerHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
u, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password)
|
||||
u, tokens, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrUsernameTaken) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username taken"})
|
||||
@@ -61,7 +65,10 @@ func registerHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, u)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": u,
|
||||
"tokens": tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// Package handler — 下载客户端管理 HTTP 端点。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// DownloadClientHandler 处理下载客户端的 CRUD 操作。
|
||||
type DownloadClientHandler struct {
|
||||
svc *service.Container
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewDownloadClientHandler 创建下载客户端处理器。
|
||||
func NewDownloadClientHandler(svc *service.Container, log *zap.Logger) *DownloadClientHandler {
|
||||
return &DownloadClientHandler{svc: svc, log: log}
|
||||
}
|
||||
|
||||
// downloadClientCreateRequest 创建下载客户端请求体。
|
||||
type downloadClientCreateRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required,oneof=qbittorrent transmission aria2"`
|
||||
Host string `json:"host" binding:"required"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// downloadClientUpdateRequest 更新下载客户端请求体。
|
||||
type downloadClientUpdateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=qbittorrent transmission aria2"`
|
||||
Host string `json:"host"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
IsDefault *bool `json:"is_default"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// Create 创建新的下载客户端。
|
||||
func (h *DownloadClientHandler) Create(c *gin.Context) {
|
||||
var req downloadClientCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// 加密密码
|
||||
password := req.Password
|
||||
if password != "" && h.svc.Crypto != nil {
|
||||
password = h.svc.Crypto.Encrypt(password)
|
||||
}
|
||||
|
||||
// 加密 Extra 配置
|
||||
extraStr := ""
|
||||
if len(req.Extra) > 0 {
|
||||
extraJSON, _ := json.Marshal(req.Extra)
|
||||
extraStr = string(extraJSON)
|
||||
if h.svc.Crypto != nil {
|
||||
extraStr = h.svc.Crypto.Encrypt(extraStr)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果设为默认,先清除其他默认
|
||||
if req.IsDefault {
|
||||
_ = h.svc.Repo.DownloadClient.ClearDefault(ctx)
|
||||
}
|
||||
|
||||
client := &model.DownloadClient{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Host: req.Host,
|
||||
Username: req.Username,
|
||||
Password: password,
|
||||
IsDefault: req.IsDefault,
|
||||
Enabled: true,
|
||||
Extra: extraStr,
|
||||
}
|
||||
|
||||
if err := h.svc.Repo.DownloadClient.Create(ctx, client); err != nil {
|
||||
Error(c, http.StatusInternalServerError, ErrInternal, "创建失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 热插拔:加载新客户端
|
||||
go func() {
|
||||
if initErr := h.svc.DownloadMgr.AddClient(ctx, client); initErr != nil {
|
||||
h.log.Warn("failed to hot-add download client", zap.Error(initErr))
|
||||
}
|
||||
}()
|
||||
|
||||
Success(c, client)
|
||||
}
|
||||
|
||||
// List 返回所有下载客户端。
|
||||
func (h *DownloadClientHandler) List(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
clients, err := h.svc.Repo.DownloadClient.List(ctx)
|
||||
if err != nil {
|
||||
Error(c, http.StatusInternalServerError, ErrInternal, "查询失败")
|
||||
return
|
||||
}
|
||||
Success(c, clients)
|
||||
}
|
||||
|
||||
// Get 返回指定下载客户端详情。
|
||||
func (h *DownloadClientHandler) Get(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
client, err := h.svc.Repo.DownloadClient.FindByID(ctx, id)
|
||||
if err != nil || client == nil {
|
||||
Error(c, http.StatusNotFound, ErrNotFound, "客户端不存在")
|
||||
return
|
||||
}
|
||||
Success(c, client)
|
||||
}
|
||||
|
||||
// Update 更新下载客户端。
|
||||
func (h *DownloadClientHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req downloadClientUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
client, err := h.svc.Repo.DownloadClient.FindByID(ctx, id)
|
||||
if err != nil || client == nil {
|
||||
Error(c, http.StatusNotFound, ErrNotFound, "客户端不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
client.Name = req.Name
|
||||
}
|
||||
if req.Type != "" {
|
||||
client.Type = req.Type
|
||||
}
|
||||
if req.Host != "" {
|
||||
client.Host = req.Host
|
||||
}
|
||||
if req.Username != "" {
|
||||
client.Username = req.Username
|
||||
}
|
||||
if req.Password != "" {
|
||||
if h.svc.Crypto != nil {
|
||||
client.Password = h.svc.Crypto.Encrypt(req.Password)
|
||||
} else {
|
||||
client.Password = req.Password
|
||||
}
|
||||
}
|
||||
if req.IsDefault != nil && *req.IsDefault {
|
||||
_ = h.svc.Repo.DownloadClient.ClearDefault(ctx)
|
||||
client.IsDefault = *req.IsDefault
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
client.Enabled = *req.Enabled
|
||||
}
|
||||
if len(req.Extra) > 0 {
|
||||
extraJSON, _ := json.Marshal(req.Extra)
|
||||
extraStr := string(extraJSON)
|
||||
if h.svc.Crypto != nil {
|
||||
client.Extra = h.svc.Crypto.Encrypt(extraStr)
|
||||
} else {
|
||||
client.Extra = extraStr
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.svc.Repo.DownloadClient.Update(ctx, client); err != nil {
|
||||
Error(c, http.StatusInternalServerError, ErrInternal, "更新失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 热更新适配器
|
||||
go func() {
|
||||
if updateErr := h.svc.DownloadMgr.UpdateClient(ctx, client); updateErr != nil {
|
||||
h.log.Warn("failed to hot-update download client", zap.Error(updateErr))
|
||||
}
|
||||
}()
|
||||
|
||||
Success(c, client)
|
||||
}
|
||||
|
||||
// Delete 删除下载客户端。
|
||||
func (h *DownloadClientHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
_, err := h.svc.Repo.DownloadClient.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
Error(c, http.StatusNotFound, ErrNotFound, "客户端不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if delErr := h.svc.Repo.DownloadClient.Delete(ctx, id); delErr != nil {
|
||||
Error(c, http.StatusInternalServerError, ErrInternal, "删除失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 热移除
|
||||
h.svc.DownloadMgr.RemoveClient(id)
|
||||
|
||||
SuccessWithMessage(c, "已删除", nil)
|
||||
}
|
||||
|
||||
// Test 测试下载客户端连接。
|
||||
func (h *DownloadClientHandler) Test(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
client, err := h.svc.Repo.DownloadClient.FindByID(ctx, id)
|
||||
if err != nil || client == nil {
|
||||
Error(c, http.StatusNotFound, ErrNotFound, "客户端不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.DownloadMgr.TestConnection(ctx, client); err != nil {
|
||||
Error(c, http.StatusBadRequest, ErrExternal, "连接测试失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
SuccessWithMessage(c, "连接成功", nil)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
{
|
||||
auth.POST("/login", loginHandler(svc))
|
||||
auth.POST("/register", registerHandler(svc))
|
||||
auth.POST("/refresh", refreshHandler(svc))
|
||||
}
|
||||
|
||||
// Authenticated endpoints.
|
||||
@@ -34,6 +35,10 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
authed.GET("/me", meHandler(svc))
|
||||
authed.PATCH("/me", updateProfileHandler(svc))
|
||||
authed.POST("/me/password", changePasswordHandler(svc))
|
||||
authed.POST("/me/logout", logoutHandler(svc))
|
||||
|
||||
// Permissions.
|
||||
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
|
||||
|
||||
// Libraries.
|
||||
authed.GET("/libraries", listLibrariesHandler(svc))
|
||||
@@ -129,6 +134,42 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
|
||||
|
||||
authed.GET("/ws", wsHandler(svc))
|
||||
|
||||
// SSE event stream.
|
||||
authed.GET("/events", sseHandler(svc))
|
||||
|
||||
// Download clients.
|
||||
authed.GET("/download-clients", listDownloadClientsHandler(svc))
|
||||
authed.POST("/download-clients", middleware.AdminRequired(), createDownloadClientHandler(svc))
|
||||
authed.GET("/download-clients/:id", getDownloadClientHandler(svc))
|
||||
authed.PUT("/download-clients/:id", middleware.AdminRequired(), updateDownloadClientHandler(svc))
|
||||
authed.DELETE("/download-clients/:id", middleware.AdminRequired(), deleteDownloadClientHandler(svc))
|
||||
authed.POST("/download-clients/:id/test", middleware.AdminRequired(), testDownloadClientHandler(svc))
|
||||
|
||||
// Notify channels.
|
||||
authed.GET("/notify-channels", listNotifyChannelsHandler(svc))
|
||||
authed.GET("/notify-channels/types", getNotifyChannelTypesHandler(svc))
|
||||
authed.POST("/notify-channels", middleware.AdminRequired(), createNotifyChannelHandler(svc))
|
||||
authed.GET("/notify-channels/:id", getNotifyChannelHandler(svc))
|
||||
authed.PUT("/notify-channels/:id", middleware.AdminRequired(), updateNotifyChannelHandler(svc))
|
||||
authed.DELETE("/notify-channels/:id", middleware.AdminRequired(), deleteNotifyChannelHandler(svc))
|
||||
authed.POST("/notify-channels/:id/test", middleware.AdminRequired(), testNotifyChannelHandler(svc))
|
||||
|
||||
// Scheduler.
|
||||
authed.GET("/scheduler/tasks", schedulerListTasksHandler(svc))
|
||||
authed.POST("/scheduler/tasks/:id/run", middleware.AdminRequired(), schedulerRunTaskHandler(svc))
|
||||
authed.GET("/scheduler/status", schedulerGetStatusHandler(svc))
|
||||
|
||||
// Sites (PT 站点管理).
|
||||
siteHandler := NewSiteHandler(svc)
|
||||
authed.GET("/sites", siteHandler.ListSites)
|
||||
authed.GET("/sites/types", siteHandler.GetSiteTypes)
|
||||
authed.GET("/sites/auth-types", siteHandler.GetAuthTypes)
|
||||
authed.POST("/sites", middleware.AdminRequired(), siteHandler.CreateSite)
|
||||
authed.GET("/sites/:id", siteHandler.GetSite)
|
||||
authed.PUT("/sites/:id", middleware.AdminRequired(), siteHandler.UpdateSite)
|
||||
authed.DELETE("/sites/:id", middleware.AdminRequired(), siteHandler.DeleteSite)
|
||||
authed.POST("/sites/:id/test", middleware.AdminRequired(), siteHandler.TestSite)
|
||||
}
|
||||
|
||||
// Admin-only endpoints.
|
||||
@@ -164,6 +205,24 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
// Scheduled jobs.
|
||||
admin.GET("/scheduler", schedulerStatusHandler(svc))
|
||||
admin.POST("/scheduler/:name/run", schedulerRunHandler(svc))
|
||||
|
||||
// User permissions management.
|
||||
admin.GET("/users/:id/permissions", getUserPermissionsHandler(svc))
|
||||
admin.PUT("/users/:id/permissions", updateUserPermissionsHandler(svc))
|
||||
admin.POST("/users/:id/permissions/reset", resetUserPermissionsHandler(svc))
|
||||
}
|
||||
|
||||
// API Config management (admin only).
|
||||
apiConfig := api.Group("/api-config")
|
||||
apiConfig.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
|
||||
{
|
||||
apiConfig.GET("", listApiConfigsHandler(svc))
|
||||
apiConfig.GET("/providers/list", listProvidersHandler(svc))
|
||||
apiConfig.GET("/:provider", getApiConfigHandler(svc))
|
||||
apiConfig.GET("/:provider/effective", getEffectiveConfigHandler(svc))
|
||||
apiConfig.POST("/:provider", upsertApiConfigHandler(svc))
|
||||
apiConfig.DELETE("/:provider", deleteApiConfigHandler(svc))
|
||||
apiConfig.POST("/:provider/test", testApiConfigHandler(svc))
|
||||
}
|
||||
|
||||
// Emby/Jellyfin compatibility shim (read-only).
|
||||
@@ -188,3 +247,198 @@ func healthCheck(c *gin.Context) {
|
||||
func versionInfo(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"name": "MediaStationGo", "version": "0.1.0"})
|
||||
}
|
||||
|
||||
// ─── 权限 Handler 包装 ────────────────────────────────────────────────────────
|
||||
|
||||
func getUserPermissionsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewPermissionHandler(svc, svc.Log)
|
||||
return h.GetUserPermissions
|
||||
}
|
||||
|
||||
func updateUserPermissionsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewPermissionHandler(svc, svc.Log)
|
||||
return h.UpdateUserPermissions
|
||||
}
|
||||
|
||||
func resetUserPermissionsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewPermissionHandler(svc, svc.Log)
|
||||
return h.ResetUserPermissions
|
||||
}
|
||||
|
||||
func getMyPermissionsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewPermissionHandler(svc, svc.Log)
|
||||
return h.GetMyPermissions
|
||||
}
|
||||
|
||||
// ─── 刷新 Handler 包装 ────────────────────────────────────────────────────────
|
||||
|
||||
func refreshHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewRefreshHandler(svc, svc.Log)
|
||||
return h.RefreshToken
|
||||
}
|
||||
|
||||
func logoutHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewRefreshHandler(svc, svc.Log)
|
||||
return h.Logout
|
||||
}
|
||||
|
||||
// ─── API Config Handler 包装 ───────────────────────────────────────────────────
|
||||
|
||||
func listApiConfigsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewApiConfigHandler(svc, svc.Log)
|
||||
return h.ListApiConfigs
|
||||
}
|
||||
|
||||
func listProvidersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewApiConfigHandler(svc, svc.Log)
|
||||
return h.ListProviders
|
||||
}
|
||||
|
||||
func getApiConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewApiConfigHandler(svc, svc.Log)
|
||||
return h.GetApiConfig
|
||||
}
|
||||
|
||||
func getEffectiveConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewApiConfigHandler(svc, svc.Log)
|
||||
return h.GetEffectiveConfig
|
||||
}
|
||||
|
||||
func upsertApiConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewApiConfigHandler(svc, svc.Log)
|
||||
return h.UpsertApiConfig
|
||||
}
|
||||
|
||||
func deleteApiConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewApiConfigHandler(svc, svc.Log)
|
||||
return h.DeleteApiConfig
|
||||
}
|
||||
|
||||
func testApiConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewApiConfigHandler(svc, svc.Log)
|
||||
return h.TestApiConfig
|
||||
}
|
||||
|
||||
// ─── Download Client Handler 包装 ─────────────────────────────────────────────
|
||||
|
||||
func listDownloadClientsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewDownloadClientHandler(svc, svc.Log)
|
||||
return h.List
|
||||
}
|
||||
|
||||
func createDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewDownloadClientHandler(svc, svc.Log)
|
||||
return h.Create
|
||||
}
|
||||
|
||||
func getDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewDownloadClientHandler(svc, svc.Log)
|
||||
return h.Get
|
||||
}
|
||||
|
||||
func updateDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewDownloadClientHandler(svc, svc.Log)
|
||||
return h.Update
|
||||
}
|
||||
|
||||
func deleteDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewDownloadClientHandler(svc, svc.Log)
|
||||
return h.Delete
|
||||
}
|
||||
|
||||
func testDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewDownloadClientHandler(svc, svc.Log)
|
||||
return h.Test
|
||||
}
|
||||
|
||||
// ─── Notify Channel Handler 包装 ──────────────────────────────────────────────
|
||||
|
||||
func listNotifyChannelsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewNotifyHandler(svc, svc.Log)
|
||||
return h.List
|
||||
}
|
||||
|
||||
func getNotifyChannelTypesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewNotifyHandler(svc, svc.Log)
|
||||
return h.GetTypes
|
||||
}
|
||||
|
||||
func createNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewNotifyHandler(svc, svc.Log)
|
||||
return h.Create
|
||||
}
|
||||
|
||||
func getNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewNotifyHandler(svc, svc.Log)
|
||||
return h.Get
|
||||
}
|
||||
|
||||
func updateNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewNotifyHandler(svc, svc.Log)
|
||||
return h.Update
|
||||
}
|
||||
|
||||
func deleteNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewNotifyHandler(svc, svc.Log)
|
||||
return h.Delete
|
||||
}
|
||||
|
||||
func testNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewNotifyHandler(svc, svc.Log)
|
||||
return h.Test
|
||||
}
|
||||
|
||||
// ─── Scheduler Handler 包装 ──────────────────────────────────────────────────
|
||||
|
||||
func schedulerListTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewSchedulerHandler(svc, svc.Log)
|
||||
return h.ListTasks
|
||||
}
|
||||
|
||||
func schedulerRunTaskHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewSchedulerHandler(svc, svc.Log)
|
||||
return h.RunTask
|
||||
}
|
||||
|
||||
func schedulerGetStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
h := NewSchedulerHandler(svc, svc.Log)
|
||||
return h.GetStatus
|
||||
}
|
||||
|
||||
// ─── SSE Handler ──────────────────────────────────────────────────────────────
|
||||
|
||||
func sseHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 获取 SSE Hub
|
||||
hub := svc.SSEHub
|
||||
|
||||
// 设置 SSE 响应头
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
// 订阅事件流
|
||||
client := hub.Subscribe()
|
||||
defer hub.Unsubscribe(client)
|
||||
|
||||
// 发送初始连接成功事件
|
||||
c.SSEvent("connected", gin.H{"status": "ok"})
|
||||
c.Writer.Flush()
|
||||
|
||||
// 持续发送事件直到客户端断开连接
|
||||
clientGone := c.Request.Context().Done()
|
||||
for {
|
||||
select {
|
||||
case <-clientGone:
|
||||
return
|
||||
case event, ok := <-client.Ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.SSEvent(event.Type, event.Payload)
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Package handler — 通知渠道管理 HTTP 端点。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// NotifyHandler 处理通知渠道的 CRUD 操作。
|
||||
type NotifyHandler struct {
|
||||
svc *service.Container
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewNotifyHandler 创建通知渠道处理器。
|
||||
func NewNotifyHandler(svc *service.Container, log *zap.Logger) *NotifyHandler {
|
||||
return &NotifyHandler{svc: svc, log: log}
|
||||
}
|
||||
|
||||
// notifyCreateRequest 创建通知渠道请求体。
|
||||
type notifyCreateRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Type string `json:"type" binding:"required,oneof=telegram wechat bark webhook email"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config map[string]string `json:"config" binding:"required"`
|
||||
Events []string `json:"events"`
|
||||
}
|
||||
|
||||
// notifyUpdateRequest 更新通知渠道请求体。
|
||||
type notifyUpdateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Config map[string]string `json:"config"`
|
||||
Events []string `json:"events"`
|
||||
}
|
||||
|
||||
// Create 创建新的通知渠道。
|
||||
func (h *NotifyHandler) Create(c *gin.Context) {
|
||||
var req notifyCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// 验证配置
|
||||
if err := h.svc.Notify.ValidateChannelConfig(req.Type, req.Config); err != nil {
|
||||
Error(c, http.StatusBadRequest, ErrInvalidParams, "配置验证失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 加密配置
|
||||
configJSON, _ := json.Marshal(req.Config)
|
||||
configStr := string(configJSON)
|
||||
if h.svc.Crypto != nil {
|
||||
configStr = h.svc.Crypto.Encrypt(configStr)
|
||||
}
|
||||
|
||||
// 序列化事件列表
|
||||
eventsJSON, _ := json.Marshal(req.Events)
|
||||
eventsStr := string(eventsJSON)
|
||||
|
||||
channel := &model.NotifyChannel{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Enabled: req.Enabled,
|
||||
Config: configStr,
|
||||
Events: eventsStr,
|
||||
}
|
||||
|
||||
if err := h.svc.Repo.NotifyChannel.Create(ctx, channel); err != nil {
|
||||
Error(c, http.StatusInternalServerError, ErrInternal, "创建失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, channel)
|
||||
}
|
||||
|
||||
// List 返回所有通知渠道。
|
||||
func (h *NotifyHandler) List(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
channels, err := h.svc.Repo.NotifyChannel.List(ctx)
|
||||
if err != nil {
|
||||
Error(c, http.StatusInternalServerError, ErrInternal, "查询失败")
|
||||
return
|
||||
}
|
||||
Success(c, channels)
|
||||
}
|
||||
|
||||
// Get 返回指定通知渠道详情。
|
||||
func (h *NotifyHandler) Get(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
channel, err := h.svc.Repo.NotifyChannel.FindByID(ctx, id)
|
||||
if err != nil || channel == nil {
|
||||
Error(c, http.StatusNotFound, ErrNotFound, "通知渠道不存在")
|
||||
return
|
||||
}
|
||||
Success(c, channel)
|
||||
}
|
||||
|
||||
// Update 更新通知渠道。
|
||||
func (h *NotifyHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req notifyUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Error(c, http.StatusBadRequest, ErrInvalidParams, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
channel, err := h.svc.Repo.NotifyChannel.FindByID(ctx, id)
|
||||
if err != nil || channel == nil {
|
||||
Error(c, http.StatusNotFound, ErrNotFound, "通知渠道不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
channel.Name = req.Name
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
channel.Enabled = *req.Enabled
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
if len(req.Config) > 0 {
|
||||
if err := h.svc.Notify.ValidateChannelConfig(channel.Type, req.Config); err != nil {
|
||||
Error(c, http.StatusBadRequest, ErrInvalidParams, "配置验证失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
configJSON, _ := json.Marshal(req.Config)
|
||||
configStr := string(configJSON)
|
||||
if h.svc.Crypto != nil {
|
||||
configStr = h.svc.Crypto.Encrypt(configStr)
|
||||
}
|
||||
channel.Config = configStr
|
||||
}
|
||||
|
||||
// 更新事件列表
|
||||
if req.Events != nil {
|
||||
eventsJSON, _ := json.Marshal(req.Events)
|
||||
channel.Events = string(eventsJSON)
|
||||
}
|
||||
|
||||
if err := h.svc.Repo.NotifyChannel.Update(ctx, channel); err != nil {
|
||||
Error(c, http.StatusInternalServerError, ErrInternal, "更新失败")
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, channel)
|
||||
}
|
||||
|
||||
// Delete 删除通知渠道。
|
||||
func (h *NotifyHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
_, err := h.svc.Repo.NotifyChannel.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
Error(c, http.StatusNotFound, ErrNotFound, "通知渠道不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if delErr := h.svc.Repo.NotifyChannel.Delete(ctx, id); delErr != nil {
|
||||
Error(c, http.StatusInternalServerError, ErrInternal, "删除失败")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessWithMessage(c, "已删除", nil)
|
||||
}
|
||||
|
||||
// Test 发送测试通知。
|
||||
func (h *NotifyHandler) Test(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if err := h.svc.Notify.SendTest(ctx, id); err != nil {
|
||||
Error(c, http.StatusBadRequest, ErrExternal, "测试通知发送失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
SuccessWithMessage(c, "测试通知已发送", nil)
|
||||
}
|
||||
|
||||
// GetTypes 返回支持的通知渠道类型列表。
|
||||
func (h *NotifyHandler) GetTypes(c *gin.Context) {
|
||||
types := h.svc.Notify.GetProviderTypes()
|
||||
Success(c, types)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package handler — 权限相关 HTTP Handler。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// PermissionHandler 权限相关 HTTP 处理。
|
||||
type PermissionHandler struct {
|
||||
svc *service.Container
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewPermissionHandler 创建权限处理器。
|
||||
func NewPermissionHandler(svc *service.Container, log *zap.Logger) *PermissionHandler {
|
||||
return &PermissionHandler{svc: svc, log: log}
|
||||
}
|
||||
|
||||
// GetUserPermissions 获取指定用户的权限(管理员)。
|
||||
// GET /api/users/:id/permissions
|
||||
func (h *PermissionHandler) GetUserPermissions(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "user id required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 权限检查:需要管理员权限
|
||||
role := middleware.GetUserRole(c)
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 40301, "message": "admin only", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
perms, err := h.svc.Permission.GetByUserID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
h.log.Error("get user permissions failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": perms, "message": "ok"})
|
||||
}
|
||||
|
||||
// UpdateUserPermissions 更新指定用户的权限(管理员)。
|
||||
// PUT /api/users/:id/permissions
|
||||
func (h *PermissionHandler) UpdateUserPermissions(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "user id required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 权限检查:需要管理员权限
|
||||
role := middleware.GetUserRole(c)
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 40301, "message": "admin only", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Permissions map[string]bool `json:"permissions"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "invalid request", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Permission.Update(c.Request.Context(), userID, req.Permissions); err != nil {
|
||||
h.log.Error("update user permissions failed", zap.Error(err), zap.String("user_id", userID))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "permissions updated", "data": nil})
|
||||
}
|
||||
|
||||
// ResetUserPermissions 重置指定用户的权限为默认值(管理员)。
|
||||
// POST /api/users/:id/permissions/reset
|
||||
func (h *PermissionHandler) ResetUserPermissions(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "user id required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 权限检查:需要管理员权限
|
||||
role := middleware.GetUserRole(c)
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 40301, "message": "admin only", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Permission.ResetToDefault(c.Request.Context(), userID); err != nil {
|
||||
h.log.Error("reset user permissions failed", zap.Error(err), zap.String("user_id", userID))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "permissions reset to default", "data": nil})
|
||||
}
|
||||
|
||||
// GetMyPermissions 获取当前用户的权限。
|
||||
// GET /api/auth/permissions
|
||||
func (h *PermissionHandler) GetMyPermissions(c *gin.Context) {
|
||||
currentUserID := middleware.GetUserID(c)
|
||||
if currentUserID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "authentication required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
perms, err := h.svc.Permission.GetPermissionMap(c.Request.Context(), currentUserID)
|
||||
if err != nil {
|
||||
h.log.Error("get my permissions failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
// 同时返回角色和等级信息
|
||||
role := middleware.GetUserRole(c)
|
||||
tier := middleware.GetUserTier(c)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": gin.H{
|
||||
"permissions": perms,
|
||||
"role": role,
|
||||
"tier": tier,
|
||||
"is_super": role == "admin" || tier == "plus",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package handler — 令牌刷新 HTTP Handler。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// RefreshHandler 令牌刷新 HTTP 处理。
|
||||
type RefreshHandler struct {
|
||||
svc *service.Container
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewRefreshHandler 创建刷新令牌处理器。
|
||||
func NewRefreshHandler(svc *service.Container, log *zap.Logger) *RefreshHandler {
|
||||
return &RefreshHandler{svc: svc, log: log}
|
||||
}
|
||||
|
||||
// RefreshTokenRequest 刷新令牌请求结构。
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
// RefreshToken 刷新访问令牌。
|
||||
// POST /api/auth/refresh
|
||||
func (h *RefreshHandler) RefreshToken(c *gin.Context) {
|
||||
var req RefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "refresh_token required", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.svc.Auth.RefreshTokens(c.Request.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
h.log.Debug("token refresh failed", zap.Error(err))
|
||||
|
||||
// 根据错误类型返回不同状态码
|
||||
switch err {
|
||||
case service.ErrInvalidRefreshToken:
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "invalid refresh token", "data": nil})
|
||||
case service.ErrTokenExpired:
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 40102, "message": "refresh token expired", "data": nil})
|
||||
case service.ErrTokenRevoked:
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 40103, "message": "refresh token revoked", "data": nil})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": gin.H{
|
||||
"token": tokens.AccessToken,
|
||||
"refresh_token": tokens.RefreshToken,
|
||||
"expires_in": tokens.ExpiresIn,
|
||||
"token_type": tokens.TokenType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Logout 登出当前用户。
|
||||
// POST /api/auth/logout
|
||||
func (h *RefreshHandler) Logout(c *gin.Context) {
|
||||
userID := c.GetString("ctx_user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": nil})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.Auth.Logout(c.Request.Context(), userID); err != nil {
|
||||
h.log.Warn("logout failed", zap.Error(err), zap.String("user_id", userID))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": nil})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package handler — 统一响应格式和错误码。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ─── 错误码定义 ───────────────────────────────────────────────────────────────
|
||||
|
||||
// 统一错误码
|
||||
const (
|
||||
ErrOK = 0
|
||||
ErrInvalidParams = 40001
|
||||
ErrUnauthorized = 40101
|
||||
ErrForbidden = 40301
|
||||
ErrNotFound = 40401
|
||||
ErrConflict = 40901
|
||||
ErrInternal = 50001
|
||||
ErrExternal = 50201
|
||||
ErrEncryptFailed = 50801
|
||||
)
|
||||
|
||||
// ─── Response Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
// APIResponse 统一 API 响应格式。
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// PaginatedResponse 分页响应格式。
|
||||
type PaginatedResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// Success 返回成功响应。
|
||||
func Success(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, APIResponse{Code: 0, Message: "ok", Data: data})
|
||||
}
|
||||
|
||||
// SuccessWithMessage 返回带消息的成功响应。
|
||||
func SuccessWithMessage(c *gin.Context, message string, data interface{}) {
|
||||
c.JSON(http.StatusOK, APIResponse{Code: 0, Message: message, Data: data})
|
||||
}
|
||||
|
||||
// Error 返回错误响应。
|
||||
func Error(c *gin.Context, httpStatus int, code int, message string) {
|
||||
c.JSON(httpStatus, APIResponse{Code: code, Message: message, Data: nil})
|
||||
}
|
||||
|
||||
// Paginated 返回分页响应。
|
||||
func Paginated(c *gin.Context, items interface{}, total int64, page, pageSize int) {
|
||||
c.JSON(http.StatusOK, PaginatedResponse{
|
||||
Code: 0,
|
||||
Message: "ok",
|
||||
Data: items,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package handler — 定时任务管理 HTTP 端点。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// SchedulerHandler 处理定时任务的查询和管理操作。
|
||||
type SchedulerHandler struct {
|
||||
svc *service.Container
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewSchedulerHandler 创建定时任务处理器。
|
||||
func NewSchedulerHandler(svc *service.Container, log *zap.Logger) *SchedulerHandler {
|
||||
return &SchedulerHandler{svc: svc, log: log}
|
||||
}
|
||||
|
||||
// ListTasks 返回所有定时任务列表。
|
||||
func (h *SchedulerHandler) ListTasks(c *gin.Context) {
|
||||
tasks := h.svc.Scheduler.Status()
|
||||
Success(c, tasks)
|
||||
}
|
||||
|
||||
// RunTask 手动触发指定任务。
|
||||
func (h *SchedulerHandler) RunTask(c *gin.Context) {
|
||||
name := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if err := h.svc.Scheduler.RunNow(ctx, name); err != nil {
|
||||
Error(c, http.StatusBadRequest, ErrInternal, "任务执行失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
SuccessWithMessage(c, "任务已触发执行", nil)
|
||||
}
|
||||
|
||||
// GetStatus 返回调度器运行状态。
|
||||
func (h *SchedulerHandler) GetStatus(c *gin.Context) {
|
||||
status := h.svc.Scheduler.Status()
|
||||
Success(c, status)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package handler — PT 站点管理 HTTP 处理。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
)
|
||||
|
||||
// SiteHandler 站点管理 CRUD。
|
||||
type SiteHandler struct {
|
||||
svc *service.Container
|
||||
}
|
||||
|
||||
// NewSiteHandler 创建站点管理 Handler。
|
||||
func NewSiteHandler(svc *service.Container) *SiteHandler {
|
||||
return &SiteHandler{svc: svc}
|
||||
}
|
||||
|
||||
// ListSites 列出所有站点。
|
||||
func (h *SiteHandler) ListSites(c *gin.Context) {
|
||||
sites, err := h.svc.Site.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if sites == nil {
|
||||
sites = []model.Site{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": sites})
|
||||
}
|
||||
|
||||
// GetSite 获取单个站点详情(解密敏感字段)。
|
||||
func (h *SiteHandler) GetSite(c *gin.Context) {
|
||||
site, err := h.svc.Site.GetByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
if err == service.ErrSiteNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 1, "message": "site not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": site})
|
||||
}
|
||||
|
||||
// CreateSite 创建站点。
|
||||
func (h *SiteHandler) CreateSite(c *gin.Context) {
|
||||
var site model.Site
|
||||
if err := c.ShouldBindJSON(&site); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
created, err := h.svc.Site.Create(c.Request.Context(), &site)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"code": 0, "message": "ok", "data": created})
|
||||
}
|
||||
|
||||
// UpdateSite 更新站点。
|
||||
func (h *SiteHandler) UpdateSite(c *gin.Context) {
|
||||
var site model.Site
|
||||
if err := c.ShouldBindJSON(&site); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
site.ID = c.Param("id")
|
||||
updated, err := h.svc.Site.Update(c.Request.Context(), &site)
|
||||
if err != nil {
|
||||
if err == service.ErrSiteNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 1, "message": "site not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": updated})
|
||||
}
|
||||
|
||||
// DeleteSite 删除站点。
|
||||
func (h *SiteHandler) DeleteSite(c *gin.Context) {
|
||||
if err := h.svc.Site.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
if err == service.ErrSiteNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 1, "message": "site not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok"})
|
||||
}
|
||||
|
||||
// TestSite 测试站点连通性。
|
||||
func (h *SiteHandler) TestSite(c *gin.Context) {
|
||||
if err := h.svc.Site.Authenticate(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok"})
|
||||
}
|
||||
|
||||
// GetSiteTypes 返回支持的站点类型列表。
|
||||
func (h *SiteHandler) GetSiteTypes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": model.SiteTypes()})
|
||||
}
|
||||
|
||||
// GetAuthTypes 返回支持的认证方式列表。
|
||||
func (h *SiteHandler) GetAuthTypes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": model.AuthTypes()})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package middleware — Emby API 兼容层认证中间件。
|
||||
// 支持 X-Emby-Token / Bearer / URL token / Username+Password 四种认证方式。
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// EmbyCtxUserID 是 Emby 认证中间件设置的用户 ID 上下文键。
|
||||
const EmbyCtxUserID = "emby_user_id"
|
||||
|
||||
// EmbyAuthRequired Emby 认证中间件。
|
||||
// 按优先级尝试以下认证方式:
|
||||
// 1. X-Emby-Token 请求头
|
||||
// 2. Authorization: Bearer <token> 请求头
|
||||
// 3. ?token=<token> URL 参数
|
||||
// 4. (仅 AuthenticateByName 端点)POST body 中的 Username+Password
|
||||
func EmbyAuthRequired(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ""
|
||||
|
||||
// 1. X-Emby-Token 头
|
||||
if t := c.GetHeader("X-Emby-Token"); t != "" {
|
||||
token = t
|
||||
}
|
||||
|
||||
// 2. Authorization: Bearer <token> 或 Emby <token>
|
||||
if token == "" {
|
||||
if authHeader := c.GetHeader("Authorization"); authHeader != "" {
|
||||
// Strip "Bearer " or "Emby " prefix
|
||||
for _, prefix := range []string{"Bearer ", "Emby "} {
|
||||
if len(authHeader) > len(prefix) && authHeader[:len(prefix)] == prefix {
|
||||
token = authHeader[len(prefix):]
|
||||
break
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
token = authHeader
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. URL 参数 token
|
||||
if token == "" {
|
||||
if t := c.Query("token"); t != "" {
|
||||
token = t
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"Code": 40101,
|
||||
"Message": "Unauthorized",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 JWT
|
||||
claims := &Claims{}
|
||||
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil || !parsed.Valid || claims.UserID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"Code": 40101,
|
||||
"Message": "Invalid token",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(EmbyCtxUserID, claims.UserID)
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
c.Set(CtxUserRole, claims.Role)
|
||||
c.Set(CtxUserTier, claims.Tier)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetEmbyUserID 从上下文中获取 Emby 用户 ID。
|
||||
func GetEmbyUserID(c *gin.Context) string {
|
||||
if uid, exists := c.Get(EmbyCtxUserID); exists {
|
||||
return uid.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package middleware exposes Gin middlewares used by the HTTP server:
|
||||
// request logging, CORS, JWT authentication and admin guard.
|
||||
// Package middleware 暴露 Gin 中间件,用于 HTTP 服务器:
|
||||
// 请求日志、CORS、JWT 认证、管理员守卫和权限检查。
|
||||
package middleware
|
||||
|
||||
import (
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
const (
|
||||
CtxUserID = "ctx_user_id"
|
||||
CtxUserRole = "ctx_user_role"
|
||||
CtxUserTier = "ctx_user_tier"
|
||||
)
|
||||
|
||||
// RequestLogger logs one structured line per request.
|
||||
@@ -65,6 +66,7 @@ func CORS(origins []string) gin.HandlerFunc {
|
||||
type Claims struct {
|
||||
UserID string `json:"uid"`
|
||||
Role string `json:"role"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -74,7 +76,7 @@ func AuthRequired(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := extractToken(c)
|
||||
if raw == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "missing token"})
|
||||
return
|
||||
}
|
||||
claims := &Claims{}
|
||||
@@ -85,11 +87,12 @@ func AuthRequired(secret string) gin.HandlerFunc {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || claims.UserID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "invalid token"})
|
||||
return
|
||||
}
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
c.Set(CtxUserRole, claims.Role)
|
||||
c.Set(CtxUserTier, claims.Tier)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -99,13 +102,65 @@ func AdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get(CtxUserRole)
|
||||
if role != "admin" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"})
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40301, "message": "admin only"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// PlusOrAdminRequired enforces role == "admin" or tier == "plus".
|
||||
func PlusOrAdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get(CtxUserRole)
|
||||
tier, _ := c.Get(CtxUserTier)
|
||||
if role != "admin" && tier != "plus" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40301, "message": "plus or admin only"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID extracts the user ID from the Gin context.
|
||||
func GetUserID(c *gin.Context) string {
|
||||
if uid, exists := c.Get(CtxUserID); exists {
|
||||
return uid.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetUserRole extracts the user role from the Gin context.
|
||||
func GetUserRole(c *gin.Context) string {
|
||||
if role, exists := c.Get(CtxUserRole); exists {
|
||||
return role.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetUserTier extracts the user tier from the Gin context.
|
||||
func GetUserTier(c *gin.Context) string {
|
||||
if tier, exists := c.Get(CtxUserTier); exists {
|
||||
return tier.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsAdmin checks if the current user is an admin.
|
||||
func IsAdmin(c *gin.Context) bool {
|
||||
return GetUserRole(c) == "admin"
|
||||
}
|
||||
|
||||
// IsPlus checks if the current user is a plus subscriber.
|
||||
func IsPlus(c *gin.Context) bool {
|
||||
return GetUserTier(c) == "plus" || GetUserRole(c) == "admin"
|
||||
}
|
||||
|
||||
// IsSuperUser checks if the current user is a super user (admin or plus).
|
||||
func IsSuperUser(c *gin.Context) bool {
|
||||
return IsAdmin(c) || IsPlus(c)
|
||||
}
|
||||
|
||||
func extractToken(c *gin.Context) string {
|
||||
if h := c.GetHeader("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package middleware — 权限检查中间件。
|
||||
// 注意:实际的权限检查在 handler 层通过 PermissionService 实现。
|
||||
// 此中间件主要用于设置上下文和基本的角色/等级检查。
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RequirePermission 创建权限检查中间件标记。
|
||||
// 实际的权限检查由 handler 中的 PermissionService 执行。
|
||||
// 此中间件确保请求已经过身份验证。
|
||||
func RequirePermission(permissionKey string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := GetUserID(c)
|
||||
role := GetUserRole(c)
|
||||
tier := GetUserTier(c)
|
||||
|
||||
if userID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"code": 40101,
|
||||
"message": "authentication required",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// admin 和 plus 用户拥有所有权限
|
||||
if role == "admin" || tier == "plus" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 将权限键存储到上下文中供 handler 使用
|
||||
c.Set("permission_key", permissionKey)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAnyPermission 创建需要任意一个权限的中间件标记。
|
||||
func RequireAnyPermission(permissionKeys ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := GetUserID(c)
|
||||
role := GetUserRole(c)
|
||||
tier := GetUserTier(c)
|
||||
|
||||
if userID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"code": 40101,
|
||||
"message": "authentication required",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// admin 和 plus 用户拥有所有权限
|
||||
if role == "admin" || tier == "plus" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 将权限键数组存储到上下文中供 handler 使用
|
||||
c.Set("permission_keys", permissionKeys)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAllPermissions 创建需要所有权限的中间件标记。
|
||||
func RequireAllPermissions(permissionKeys ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := GetUserID(c)
|
||||
role := GetUserRole(c)
|
||||
tier := GetUserTier(c)
|
||||
|
||||
if userID == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"code": 40101,
|
||||
"message": "authentication required",
|
||||
"data": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// admin 和 plus 用户拥有所有权限
|
||||
if role == "admin" || tier == "plus" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("permission_keys", permissionKeys)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetPermissionKey 从上下文中获取存储的权限键。
|
||||
func GetPermissionKey(c *gin.Context) string {
|
||||
if key, exists := c.Get("permission_key"); exists {
|
||||
return key.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetPermissionKeys 从上下文中获取存储的权限键数组。
|
||||
func GetPermissionKeys(c *gin.Context) []string {
|
||||
if keys, exists := c.Get("permission_keys"); exists {
|
||||
return keys.([]string)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package model 定义第三方 API 配置数据模型。
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ApiConfig 存储第三方 API 密钥和配置信息。
|
||||
// APIKey 字段在 JSON 序列化时隐藏(json:"-"),通过加密存储。
|
||||
type ApiConfig struct {
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
Provider string `gorm:"size:64;uniqueIndex;not null" json:"provider"`
|
||||
APIKey string `gorm:"size:512" json:"-"`
|
||||
BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
|
||||
Extra string `gorm:"type:text" json:"extra,omitempty"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Description string `gorm:"size:255" json:"description,omitempty"`
|
||||
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
||||
TestResult string `gorm:"size:32" json:"test_result,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BeforeCreate 生成 UUID。
|
||||
func (c *ApiConfig) BeforeCreate(_ *gorm.DB) error {
|
||||
if c.ID == "" {
|
||||
c.ID = uuid.NewString()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate 更新时间戳。
|
||||
func (c *ApiConfig) BeforeUpdate(_ *gorm.DB) error {
|
||||
c.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApiProvider 定义支持的 API 提供者列表。
|
||||
type ApiProvider struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
HasBaseURL bool `json:"has_base_url"`
|
||||
}
|
||||
|
||||
// PredefinedProviders 返回预定义的 API 提供者列表。
|
||||
func PredefinedProviders() []ApiProvider {
|
||||
return []ApiProvider{
|
||||
{ID: "tmdb", Name: "TMDb", Description: "The Movie Database - 电影/剧集元数据", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "douban", Name: "豆瓣", Description: "豆瓣电影/音乐/书籍数据", HasAPIKey: true, HasBaseURL: false},
|
||||
{ID: "bangumi", Name: "Bangumi", Description: "番剧/动漫数据库", HasAPIKey: true, HasBaseURL: false},
|
||||
{ID: "thetvdb", Name: "TheTVDB", Description: "TV Series Database", HasAPIKey: true, HasBaseURL: false},
|
||||
{ID: "fanart", Name: "Fanart.tv", Description: "影视海报/背景图", HasAPIKey: true, HasBaseURL: false},
|
||||
{ID: "openai", Name: "OpenAI", Description: "GPT 系列模型", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "deepseek", Name: "DeepSeek", Description: "DeepSeek 大模型", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "siliconflow", Name: "SiliconFlow", Description: "AI 模型聚合 API", HasAPIKey: true, HasBaseURL: true},
|
||||
{ID: "adult", Name: "Adult API", Description: "Adult 内容元数据(需额外权限)", HasAPIKey: true, HasBaseURL: false},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Package model 定义下载客户端配置数据模型。
|
||||
package model
|
||||
|
||||
// DownloadClient 下载客户端配置(qBittorrent / Transmission / Aria2)。
|
||||
// 支持多客户端并行运行,一个标记为默认客户端。
|
||||
type DownloadClient struct {
|
||||
Base
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Type string `gorm:"size:32;not null" json:"type"` // qbittorrent / transmission / aria2
|
||||
Host string `gorm:"size:512;not null" json:"host"` // http://host:port
|
||||
Username string `gorm:"size:256" json:"username"`
|
||||
Password string `gorm:"size:1024" json:"-"` // AES加密存储
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Extra string `gorm:"type:text" json:"-"` // JSON配置, AES加密
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
// Package model — Emby API 兼容层请求/响应类型。
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ─── Emby 认证 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// EmbyAuthRequest Emby 认证请求(用户名+密码)。
|
||||
type EmbyAuthRequest struct {
|
||||
Username string `json:"Username"`
|
||||
Password string `json:"Password"`
|
||||
}
|
||||
|
||||
// EmbyAuthResponse Emby 认证响应。
|
||||
type EmbyAuthResponse struct {
|
||||
User EmbyUser `json:"User"`
|
||||
AccessToken string `json:"AccessToken"`
|
||||
ServerID string `json:"ServerId"`
|
||||
}
|
||||
|
||||
// EmbyApiKeyAuthRequest API Key 认证请求。
|
||||
type EmbyApiKeyAuthRequest struct {
|
||||
ApiKey string `json:"ApiKey"`
|
||||
}
|
||||
|
||||
// ─── Emby 用户 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// EmbyUser Emby 用户信息。
|
||||
type EmbyUser struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
ServerId string `json:"ServerId"`
|
||||
HasPassword bool `json:"HasPassword"`
|
||||
PrimaryImageTag string `json:"PrimaryImageTag,omitempty"`
|
||||
Configuration *EmbyUserConfiguration `json:"Configuration,omitempty"`
|
||||
LastActivityDate *time.Time `json:"LastActivityDate,omitempty"`
|
||||
LastLoginDate *time.Time `json:"LastLoginDate,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyUserConfiguration 用户配置。
|
||||
type EmbyUserConfiguration struct {
|
||||
AudioLanguagePreference string `json:"AudioLanguagePreference"`
|
||||
SubtitleLanguagePreference string `json:"SubtitleLanguagePreference"`
|
||||
EnableAutoPlay bool `json:"EnableAutoPlay"`
|
||||
EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"`
|
||||
}
|
||||
|
||||
// ─── Emby 系统信息 ────────────────────────────────────────────────────────────
|
||||
|
||||
// EmbySystemInfo 系统信息。
|
||||
type EmbySystemInfo struct {
|
||||
Id string `json:"Id"`
|
||||
ServerName string `json:"ServerName"`
|
||||
Version string `json:"Version"`
|
||||
ProductName string `json:"ProductName"`
|
||||
OperatingSystem string `json:"OperatingSystem"`
|
||||
Architecture string `json:"Architecture"`
|
||||
LocalAddress string `json:"LocalAddress"`
|
||||
WanAddress string `json:"WanAddress,omitempty"`
|
||||
HasPendingRestart bool `json:"HasPendingRestart"`
|
||||
IsShuttingDown bool `json:"IsShuttingDown"`
|
||||
SupportsLibraryScan bool `json:"SupportsLibraryScan"`
|
||||
SupportsHttps bool `json:"SupportsHttps"`
|
||||
SupportsAutoDiscovery bool `json:"SupportsAutoDiscovery"`
|
||||
WebSocketPortNumber int `json:"WebSocketPortNumber"`
|
||||
TranscodingTempPath string `json:"TranscodingTempPath,omitempty"`
|
||||
CanSelfUpdate bool `json:"CanSelfUpdate"`
|
||||
CanLaunchWebBrowser bool `json:"CanLaunchWebBrowser"`
|
||||
CanRestart bool `json:"CanRestart"`
|
||||
CodecCount int `json:"CodecCount"`
|
||||
}
|
||||
|
||||
// EmbyLogEntry 日志条目。
|
||||
type EmbyLogEntry struct {
|
||||
Id string `json:"Id"`
|
||||
DateCreated time.Time `json:"DateCreated"`
|
||||
Level string `json:"Level"`
|
||||
Message string `json:"Message"`
|
||||
}
|
||||
|
||||
// EmbyServerConfiguration 服务器配置。
|
||||
type EmbyServerConfiguration struct {
|
||||
Name string `json:"Name"`
|
||||
ServerName string `json:"ServerName"`
|
||||
EnableUPnP bool `json:"EnableUPnP"`
|
||||
PublicPort int `json:"PublicPort"`
|
||||
EnableHttps bool `json:"EnableHttps"`
|
||||
HttpServerPortNumber int `json:"HttpServerPortNumber"`
|
||||
HttpsPortNumber int `json:"HttpsPortNumber"`
|
||||
EnableRemoteAccess bool `json:"EnableRemoteAccess"`
|
||||
}
|
||||
|
||||
// EmbySession 会话信息。
|
||||
type EmbySession struct {
|
||||
Id string `json:"Id"`
|
||||
Client string `json:"Client"`
|
||||
ClientVersion string `json:"ClientVersion"`
|
||||
DeviceId string `json:"DeviceId"`
|
||||
DeviceName string `json:"DeviceName"`
|
||||
UserName string `json:"UserName,omitempty"`
|
||||
UserId string `json:"UserId,omitempty"`
|
||||
LastActivityDate *time.Time `json:"LastActivityDate,omitempty"`
|
||||
RemoteEndPoint string `json:"RemoteEndPoint,omitempty"`
|
||||
NowPlayingItem *EmbyItem `json:"NowPlayingItem,omitempty"`
|
||||
PlayState *EmbyPlaybackState `json:"PlayState,omitempty"`
|
||||
Capabilities EmbyClientCapabilities `json:"Capabilities,omitempty"`
|
||||
SupportsRemoteControl bool `json:"SupportsRemoteControl"`
|
||||
AdditionalUsers []EmbySessionUserInfo `json:"AdditionalUsers,omitempty"`
|
||||
}
|
||||
|
||||
// EmbySessionUserInfo 会话中的附加用户。
|
||||
type EmbySessionUserInfo struct {
|
||||
UserId string `json:"UserId"`
|
||||
UserName string `json:"UserName"`
|
||||
}
|
||||
|
||||
// EmbyPlaybackState 播放状态。
|
||||
type EmbyPlaybackState struct {
|
||||
PositionTicks int64 `json:"PositionTicks"`
|
||||
VolumeLevel int `json:"VolumeLevel"`
|
||||
IsMuted bool `json:"IsMuted"`
|
||||
IsPaused bool `json:"IsPaused"`
|
||||
PlayMethod string `json:"PlayMethod,omitempty"`
|
||||
CanSeek bool `json:"CanSeek"`
|
||||
}
|
||||
|
||||
// EmbyClientCapabilities 客户端能力描述。
|
||||
type EmbyClientCapabilities struct {
|
||||
PlayableMediaTypes []string `json:"PlayableMediaTypes"`
|
||||
SupportedCommands []string `json:"SupportedCommands"`
|
||||
SupportsMediaControl bool `json:"SupportsMediaControl"`
|
||||
SupportsSync bool `json:"SupportsSync"`
|
||||
}
|
||||
|
||||
// ─── Emby 虚拟文件夹 / 媒体库 ────────────────────────────────────────────────
|
||||
|
||||
// EmbyVirtualFolder 虚拟文件夹(媒体库)。
|
||||
type EmbyVirtualFolder struct {
|
||||
Name string `json:"Name"`
|
||||
Locations []string `json:"Locations"`
|
||||
CollectionType string `json:"CollectionType"`
|
||||
LibraryOptions EmbyLibraryOptions `json:"LibraryOptions,omitempty"`
|
||||
RefreshStatus *EmbyRefreshStatus `json:"RefreshStatus,omitempty"`
|
||||
ItemId string `json:"ItemId"`
|
||||
}
|
||||
|
||||
// EmbyLibraryOptions 媒体库选项。
|
||||
type EmbyLibraryOptions struct {
|
||||
PreferredMetadataLanguage string `json:"PreferredMetadataLanguage"`
|
||||
MetadataCountryCode string `json:"MetadataCountryCode"`
|
||||
EnableRealtimeMonitor bool `json:"EnableRealtimeMonitor"`
|
||||
EnableAutomaticSeriesGrouping bool `json:"EnableAutomaticSeriesGrouping"`
|
||||
}
|
||||
|
||||
// EmbyRefreshStatus 刷新状态。
|
||||
type EmbyRefreshStatus struct {
|
||||
LastRefreshResult string `json:"LastRefreshResult"`
|
||||
LastRefreshedAt time.Time `json:"LastRefreshedAt"`
|
||||
IsActive bool `json:"IsActive"`
|
||||
}
|
||||
|
||||
// EmbyItemsCounts 项目计数。
|
||||
type EmbyItemsCounts struct {
|
||||
MovieCount int `json:"MovieCount"`
|
||||
SeriesCount int `json:"SeriesCount"`
|
||||
EpisodeCount int `json:"EpisodeCount"`
|
||||
ArtistCount int `json:"ArtistCount"`
|
||||
AlbumCount int `json:"AlbumCount"`
|
||||
SongCount int `json:"SongCount"`
|
||||
MusicVideoCount int `json:"MusicVideoCount"`
|
||||
BookCount int `json:"BookCount"`
|
||||
BoxSetCount int `json:"BoxSetCount"`
|
||||
}
|
||||
|
||||
// ─── Emby Items ───────────────────────────────────────────────────────────────
|
||||
|
||||
// EmbyItemsResponse Emby 标准分页响应包装。
|
||||
type EmbyItemsResponse struct {
|
||||
Items []EmbyItem `json:"Items"`
|
||||
TotalRecordCount int `json:"TotalRecordCount"`
|
||||
StartIndex int `json:"StartIndex"`
|
||||
}
|
||||
|
||||
// EmbyItem Emby 媒体项。
|
||||
type EmbyItem struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"` // Movie / Series / Episode / Season / BoxSet / Folder
|
||||
Overview string `json:"Overview,omitempty"`
|
||||
ProductionYear int `json:"ProductionYear,omitempty"`
|
||||
PremiereDate *time.Time `json:"PremiereDate,omitempty"`
|
||||
CommunityRating float64 `json:"CommunityRating,omitempty"`
|
||||
OfficialRating string `json:"OfficialRating,omitempty"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
||||
ParentId string `json:"ParentId,omitempty"`
|
||||
SeriesId string `json:"SeriesId,omitempty"`
|
||||
SeasonId string `json:"SeasonId,omitempty"`
|
||||
IndexNumber int `json:"IndexNumber,omitempty"`
|
||||
ParentIndexNumber int `json:"ParentIndexNumber,omitempty"`
|
||||
UserData *EmbyUserData `json:"UserData,omitempty"`
|
||||
ImageTags map[string]string `json:"ImageTags,omitempty"`
|
||||
BackdropImageTags []string `json:"BackdropImageTags,omitempty"`
|
||||
Genres []string `json:"Genres,omitempty"`
|
||||
Studios []EmbyNameId `json:"Studios,omitempty"`
|
||||
People []EmbyPerson `json:"People,omitempty"`
|
||||
MediaSources []EmbyMediaSource `json:"MediaSources,omitempty"`
|
||||
RecursiveItemCount int `json:"RecursiveItemCount,omitempty"`
|
||||
ChildCount int `json:"ChildCount,omitempty"`
|
||||
Status string `json:"Status,omitempty"`
|
||||
AirDays []string `json:"AirDays,omitempty"`
|
||||
EndDate *time.Time `json:"EndDate,omitempty"`
|
||||
ProviderIds map[string]string `json:"ProviderIds,omitempty"`
|
||||
Taglines []string `json:"Taglines,omitempty"`
|
||||
GenreItems []EmbyNameId `json:"GenreItems,omitempty"`
|
||||
DateCreated *time.Time `json:"DateCreated,omitempty"`
|
||||
Path string `json:"Path,omitempty"`
|
||||
SortName string `json:"SortName,omitempty"`
|
||||
ForcedSortName string `json:"ForcedSortName,omitempty"`
|
||||
Width int `json:"Width,omitempty"`
|
||||
Height int `json:"Height,omitempty"`
|
||||
Container string `json:"Container,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyUserData 用户播放数据。
|
||||
type EmbyUserData struct {
|
||||
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
||||
PlayCount int `json:"PlayCount"`
|
||||
IsFavorite bool `json:"IsFavorite"`
|
||||
Played bool `json:"Played"`
|
||||
UnplayedItemCount int `json:"UnplayedItemCount"`
|
||||
PercentagePlayed float64 `json:"PercentagePlayed"`
|
||||
Rating float64 `json:"Rating,omitempty"`
|
||||
PlayedPercentage float64 `json:"PlayedPercentage,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyMediaSource 媒体源。
|
||||
type EmbyMediaSource struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Path string `json:"Path"`
|
||||
Size int64 `json:"Size"`
|
||||
Container string `json:"Container,omitempty"`
|
||||
Bitrate int64 `json:"Bitrate,omitempty"`
|
||||
MediaStreams []EmbyMediaStream `json:"MediaStreams"`
|
||||
SupportsTranscoding bool `json:"SupportsTranscoding"`
|
||||
SupportsDirectStream bool `json:"SupportsDirectStream"`
|
||||
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
|
||||
TranscodingUrl string `json:"TranscodingUrl,omitempty"`
|
||||
Protocol string `json:"Protocol,omitempty"`
|
||||
Type string `json:"Type,omitempty"`
|
||||
IsRemote bool `json:"IsRemote,omitempty"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
||||
ETag string `json:"ETag,omitempty"`
|
||||
SupportsProbing bool `json:"SupportsProbing,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyMediaStream 媒体流(视频/音频/字幕)。
|
||||
type EmbyMediaStream struct {
|
||||
Codec string `json:"Codec"`
|
||||
Type string `json:"Type"` // Video / Audio / Subtitle
|
||||
Language string `json:"Language,omitempty"`
|
||||
DisplayTitle string `json:"DisplayTitle,omitempty"`
|
||||
Index int `json:"Index"`
|
||||
IsDefault bool `json:"IsDefault"`
|
||||
IsForced bool `json:"IsForced"`
|
||||
IsExternal bool `json:"IsExternal,omitempty"`
|
||||
Height int `json:"Height,omitempty"`
|
||||
Width int `json:"Width,omitempty"`
|
||||
BitRate int64 `json:"BitRate,omitempty"`
|
||||
Channels int `json:"Channels,omitempty"`
|
||||
SampleRate int `json:"SampleRate,omitempty"`
|
||||
AspectRatio string `json:"AspectRatio,omitempty"`
|
||||
VideoRange string `json:"VideoRange,omitempty"`
|
||||
DeliveryUrl string `json:"DeliveryUrl,omitempty"`
|
||||
DeliveryMethod string `json:"DeliveryMethod,omitempty"`
|
||||
ExternalUrl string `json:"ExternalUrl,omitempty"`
|
||||
ExternalSubtitleId string `json:"ExternalSubtitleId,omitempty"`
|
||||
SubtitleFileName string `json:"SubtitleFileName,omitempty"`
|
||||
Title string `json:"Title,omitempty"`
|
||||
Comment string `json:"Comment,omitempty"`
|
||||
Path string `json:"Path,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyPerson 人员信息。
|
||||
type EmbyPerson struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Role string `json:"Role,omitempty"`
|
||||
Type string `json:"Type,omitempty"`
|
||||
PrimaryImageTag string `json:"PrimaryImageTag,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyNameId 名称+ID 对。
|
||||
type EmbyNameId struct {
|
||||
Name string `json:"Name"`
|
||||
Id string `json:"Id"`
|
||||
}
|
||||
|
||||
// ─── Emby PlaybackInfo ────────────────────────────────────────────────────────
|
||||
|
||||
// EmbyPlaybackInfoRequest 播放信息请求。
|
||||
type EmbyPlaybackInfoRequest struct {
|
||||
UserId string `json:"UserId,omitempty"`
|
||||
MaxStreamingBitrate int64 `json:"MaxStreamingBitrate,omitempty"`
|
||||
StartTimeTicks int64 `json:"StartTimeTicks,omitempty"`
|
||||
AudioStreamIndex int `json:"AudioStreamIndex,omitempty"`
|
||||
SubtitleStreamIndex int `json:"SubtitleStreamIndex,omitempty"`
|
||||
MaxAudioChannels int `json:"MaxAudioChannels,omitempty"`
|
||||
ItemId string `json:"ItemId,omitempty"`
|
||||
DeviceProfile *EmbyDeviceProfile `json:"DeviceProfile,omitempty"`
|
||||
EnableDirectStream bool `json:"EnableDirectStream,omitempty"`
|
||||
EnableDirectPlay bool `json:"EnableDirectPlay,omitempty"`
|
||||
AutoOpenLiveStream bool `json:"AutoOpenLiveStream,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyPlaybackInfoResponse 播放信息响应。
|
||||
type EmbyPlaybackInfoResponse struct {
|
||||
MediaSources []EmbyMediaSource `json:"MediaSources"`
|
||||
PlaySessionId string `json:"PlaySessionId"`
|
||||
}
|
||||
|
||||
// EmbyDeviceProfile 设备配置文件。
|
||||
type EmbyDeviceProfile struct {
|
||||
Name string `json:"Name,omitempty"`
|
||||
MaxStaticBitrate int `json:"MaxStaticBitrate,omitempty"`
|
||||
MaxStreamingBitrate int `json:"MaxStreamingBitrate,omitempty"`
|
||||
MusicStreamingTranscodingBitrate int `json:"MusicStreamingTranscodingBitrate,omitempty"`
|
||||
DirectPlayProfiles []EmbyDirectPlayProfile `json:"DirectPlayProfiles,omitempty"`
|
||||
TranscodingProfiles []EmbyTranscodingProfile `json:"TranscodingProfiles,omitempty"`
|
||||
ContainerProfiles []EmbyContainerProfile `json:"ContainerProfiles,omitempty"`
|
||||
CodecProfiles []EmbyCodecProfile `json:"CodecProfiles,omitempty"`
|
||||
SubtitleProfiles []EmbySubtitleProfile `json:"SubtitleProfiles,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyDirectPlayProfile 直接播放配置。
|
||||
type EmbyDirectPlayProfile struct {
|
||||
Container string `json:"Container,omitempty"`
|
||||
AudioCodec string `json:"AudioCodec,omitempty"`
|
||||
VideoCodec string `json:"VideoCodec,omitempty"`
|
||||
Type string `json:"Type,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyTranscodingProfile 转码配置。
|
||||
type EmbyTranscodingProfile struct {
|
||||
Container string `json:"Container,omitempty"`
|
||||
Type string `json:"Type,omitempty"`
|
||||
VideoCodec string `json:"VideoCodec,omitempty"`
|
||||
AudioCodec string `json:"AudioCodec,omitempty"`
|
||||
Protocol string `json:"Protocol,omitempty"`
|
||||
EstimateContentLength bool `json:"EstimateContentLength,omitempty"`
|
||||
EnableMpegtsM2TsMode bool `json:"EnableMpegtsM2TsMode,omitempty"`
|
||||
TranscodeSeekInfo string `json:"TranscodeSeekInfo,omitempty"`
|
||||
Context string `json:"Context,omitempty"`
|
||||
EnableSubtitlesInManifest bool `json:"EnableSubtitlesInManifest,omitempty"`
|
||||
MaxAudioChannels int `json:"MaxAudioChannels,omitempty"`
|
||||
MinSegments int `json:"MinSegments,omitempty"`
|
||||
SegmentLength int `json:"SegmentLength,omitempty"`
|
||||
BreakOnNonKeyFrames bool `json:"BreakOnNonKeyFrames,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyContainerProfile 容器配置。
|
||||
type EmbyContainerProfile struct {
|
||||
Type string `json:"Type,omitempty"`
|
||||
Conditions []string `json:"Conditions,omitempty"`
|
||||
Container string `json:"Container,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyCodecProfile 编解码器配置。
|
||||
type EmbyCodecProfile struct {
|
||||
Type string `json:"Type,omitempty"`
|
||||
Conditions []EmbyProfileCondition `json:"Conditions,omitempty"`
|
||||
Codec string `json:"Codec,omitempty"`
|
||||
Container string `json:"Container,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyProfileCondition 配置条件。
|
||||
type EmbyProfileCondition struct {
|
||||
Condition string `json:"Condition,omitempty"`
|
||||
Property string `json:"Property,omitempty"`
|
||||
Value string `json:"Value,omitempty"`
|
||||
IsRequired bool `json:"IsRequired,omitempty"`
|
||||
}
|
||||
|
||||
// EmbySubtitleProfile 字幕配置。
|
||||
type EmbySubtitleProfile struct {
|
||||
Format string `json:"Format,omitempty"`
|
||||
Method string `json:"Method,omitempty"`
|
||||
DidlMode string `json:"DidlMode,omitempty"`
|
||||
Language string `json:"Language,omitempty"`
|
||||
Container string `json:"Container,omitempty"`
|
||||
}
|
||||
|
||||
// ─── Emby 播放进度上报 ────────────────────────────────────────────────────────
|
||||
|
||||
// EmbyPlaybackProgressRequest 播放进度上报。
|
||||
type EmbyPlaybackProgressRequest struct {
|
||||
CanSeek bool `json:"CanSeek"`
|
||||
ItemId string `json:"ItemId"`
|
||||
MediaSourceId string `json:"MediaSourceId,omitempty"`
|
||||
PositionTicks int64 `json:"PositionTicks"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
||||
IsPaused bool `json:"IsPaused"`
|
||||
IsMuted bool `json:"IsMuted"`
|
||||
VolumeLevel int `json:"VolumeLevel,omitempty"`
|
||||
PlayMethod string `json:"PlayMethod,omitempty"`
|
||||
PlaySessionId string `json:"PlaySessionId,omitempty"`
|
||||
LiveStreamId string `json:"LiveStreamId,omitempty"`
|
||||
QueueableMediaTypes []string `json:"QueueableMediaTypes,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyStopPlaybackRequest 停止播放上报。
|
||||
type EmbyStopPlaybackRequest struct {
|
||||
ItemId string `json:"ItemId"`
|
||||
MediaSourceId string `json:"MediaSourceId,omitempty"`
|
||||
PositionTicks int64 `json:"PositionTicks"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
|
||||
PlaySessionId string `json:"PlaySessionId,omitempty"`
|
||||
LiveStreamId string `json:"LiveStreamId,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyUserDataRequest 用户数据更新。
|
||||
type EmbyUserDataRequest struct {
|
||||
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks,omitempty"`
|
||||
PlayCount int `json:"PlayCount,omitempty"`
|
||||
IsFavorite bool `json:"IsFavorite,omitempty"`
|
||||
Played bool `json:"Played,omitempty"`
|
||||
PlayedPercentage float64 `json:"PlayedPercentage,omitempty"`
|
||||
}
|
||||
|
||||
// ─── Emby Hubs ────────────────────────────────────────────────────────────────
|
||||
|
||||
// EmbyHubResponse Hub 响应。
|
||||
type EmbyHubResponse struct {
|
||||
Items []EmbyHubItem `json:"Items"`
|
||||
}
|
||||
|
||||
// EmbyHubItem Hub 条目。
|
||||
type EmbyHubItem struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
Items []EmbyItem `json:"Items"`
|
||||
TotalCount int `json:"TotalCount,omitempty"`
|
||||
ImageUrl string `json:"ImageUrl,omitempty"`
|
||||
}
|
||||
|
||||
// ─── Emby 字幕 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// EmbyRemoteSubtitleInfo 远程字幕信息。
|
||||
type EmbyRemoteSubtitleInfo struct {
|
||||
ThreeLetterISOLanguageName string `json:"ThreeLetterISOLanguageName"`
|
||||
Id string `json:"Id"`
|
||||
ProviderName string `json:"ProviderName"`
|
||||
Name string `json:"Name"`
|
||||
Format string `json:"Format"`
|
||||
Author string `json:"Author"`
|
||||
Comment string `json:"Comment"`
|
||||
DateCreated *time.Time `json:"DateCreated,omitempty"`
|
||||
CommunityRating float64 `json:"CommunityRating,omitempty"`
|
||||
DownloadCount int `json:"DownloadCount"`
|
||||
IsHashMatch bool `json:"IsHashMatch,omitempty"`
|
||||
IsForced bool `json:"IsForced,omitempty"`
|
||||
IsHearingImpaired bool `json:"IsHearingImpaired,omitempty"`
|
||||
}
|
||||
|
||||
// EmbySubtitleSearchRequest 字幕搜索请求。
|
||||
type EmbySubtitleSearchRequest struct {
|
||||
ItemId string `json:"ItemId"`
|
||||
Language string `json:"Language"`
|
||||
IsPerfectMatch bool `json:"IsPerfectMatch,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyImageRemoteInfo 远程图片信息。
|
||||
type EmbyImageRemoteInfo struct {
|
||||
Providers []EmbyImageProviderInfo `json:"Providers"`
|
||||
TotalRecordCount int `json:"TotalRecordCount"`
|
||||
}
|
||||
|
||||
// EmbyImageProviderInfo 图片提供者信息。
|
||||
type EmbyImageProviderInfo struct {
|
||||
Name string `json:"Name"`
|
||||
RemoteImages []EmbyRemoteImageInfo `json:"RemoteImages,omitempty"`
|
||||
SupportedImages []string `json:"SupportedImages"`
|
||||
}
|
||||
|
||||
// EmbyRemoteImageInfo 远程图片信息。
|
||||
type EmbyRemoteImageInfo struct {
|
||||
Url string `json:"Url"`
|
||||
ThumbnailUrl string `json:"ThumbnailUrl,omitempty"`
|
||||
Height int `json:"Height"`
|
||||
Width int `json:"Width"`
|
||||
CommunityRating float64 `json:"CommunityRating,omitempty"`
|
||||
VoteCount int `json:"VoteCount,omitempty"`
|
||||
Language string `json:"Language,omitempty"`
|
||||
Type string `json:"Type"`
|
||||
RatingType string `json:"RatingType,omitempty"`
|
||||
ProviderName string `json:"ProviderName"`
|
||||
}
|
||||
|
||||
// ─── Emby Genre / Person ─────────────────────────────────────────────────────
|
||||
|
||||
// EmbyGenre 类型。
|
||||
type EmbyGenre struct {
|
||||
Name string `json:"Name"`
|
||||
Id string `json:"Id,omitempty"`
|
||||
}
|
||||
|
||||
// EmbyPersonInfo 人物信息。
|
||||
type EmbyPersonInfo struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type,omitempty"`
|
||||
PrimaryImageTag string `json:"PrimaryImageTag,omitempty"`
|
||||
Overview string `json:"Overview,omitempty"`
|
||||
BirthDate string `json:"BirthDate,omitempty"`
|
||||
ProductionYear int `json:"ProductionYear,omitempty"`
|
||||
EndDate string `json:"EndDate,omitempty"`
|
||||
PremiereDate *time.Time `json:"PremiereDate,omitempty"`
|
||||
}
|
||||
|
||||
// ─── Emby Active Encoding ────────────────────────────────────────────────────
|
||||
|
||||
// EmbyActiveEncodingRequest 活跃编码请求(客户端报告转码进度)。
|
||||
type EmbyActiveEncodingRequest struct {
|
||||
PlaySessionId string `json:"PlaySessionId"`
|
||||
When string `json:"When"`
|
||||
PositionTicks int64 `json:"PositionTicks,omitempty"`
|
||||
IsPaused bool `json:"IsPaused,omitempty"`
|
||||
IsUserPaused bool `json:"IsUserPaused,omitempty"`
|
||||
}
|
||||
+36
-30
@@ -1,6 +1,5 @@
|
||||
// Package model defines GORM data models and the registry used by
|
||||
// auto-migration. Each subsystem in MediaStationGo owns a slice of tables
|
||||
// here; AllModels returns the union for db.AutoMigrate.
|
||||
// Package model 定义 GORM 数据模型和自动迁移使用的注册表。
|
||||
// 每个子系统在 MediaStationGo 中拥有一个表切片;AllModels 返回联合以供 db.AutoMigrate 使用。
|
||||
package model
|
||||
|
||||
import (
|
||||
@@ -10,11 +9,11 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Base captures the fields embedded in every domain entity:
|
||||
// Base 嵌入每个域实体共享的字段:
|
||||
//
|
||||
// - ID: UUID v4 string primary key.
|
||||
// - CreatedAt / UpdatedAt: managed by GORM.
|
||||
// - DeletedAt: soft-delete (queries auto-filter on it).
|
||||
// - ID: UUID v4 字符串主键
|
||||
// - CreatedAt / UpdatedAt: 由 GORM 管理
|
||||
// - DeletedAt: 软删除(查询自动过滤)
|
||||
type Base struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -22,7 +21,7 @@ type Base struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// BeforeCreate generates a UUID if the caller did not supply one.
|
||||
// BeforeCreate 如果调用者未提供则生成 UUID。
|
||||
func (b *Base) BeforeCreate(_ *gorm.DB) error {
|
||||
if b.ID == "" {
|
||||
b.ID = uuid.NewString()
|
||||
@@ -30,20 +29,23 @@ func (b *Base) BeforeCreate(_ *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// User is a local account. The first registered admin (or seeded admin)
|
||||
// gains the "admin" role; everyone else defaults to "user".
|
||||
// User 是本地账户。第一个注册的管理员(或种子管理员)获得 "admin" 角色;
|
||||
// 其他所有用户默认为 "user"。
|
||||
type User struct {
|
||||
Base
|
||||
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
|
||||
PasswordHash string `gorm:"size:128;not null" json:"-"`
|
||||
Role string `gorm:"size:16;not null;default:user" json:"role"`
|
||||
Tier string `gorm:"size:16;default:free" json:"tier"` // free / plus
|
||||
Nickname string `gorm:"size:128" json:"nickname,omitempty"`
|
||||
Email string `gorm:"size:128" json:"email,omitempty"`
|
||||
AvatarURL string `gorm:"size:255" json:"avatar_url,omitempty"`
|
||||
ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
// Library represents a user-defined media root directory.
|
||||
// Library 表示用户定义的媒体根目录。
|
||||
type Library struct {
|
||||
Base
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
@@ -52,8 +54,7 @@ type Library struct {
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
}
|
||||
|
||||
// Media is a single playable item. Series episodes link to a SeriesID; movies
|
||||
// have SeriesID == "".
|
||||
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
|
||||
type Media struct {
|
||||
Base
|
||||
LibraryID string `gorm:"index;size:36" json:"library_id"`
|
||||
@@ -116,7 +117,7 @@ type APIConfig struct {
|
||||
Description string `gorm:"size:255" json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// Series groups episodes that belong to the same show.
|
||||
// Series 将属于同一节目的剧集分组。
|
||||
type Series struct {
|
||||
Base
|
||||
LibraryID string `gorm:"index;size:36" json:"library_id"`
|
||||
@@ -130,7 +131,7 @@ type Series struct {
|
||||
BangumiID int `json:"bangumi_id"`
|
||||
}
|
||||
|
||||
// PlaybackHistory records the current playback position for resume support.
|
||||
// PlaybackHistory 记录当前播放位置以支持续播。
|
||||
type PlaybackHistory struct {
|
||||
Base
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
@@ -141,14 +142,14 @@ type PlaybackHistory struct {
|
||||
Completed bool `json:"completed"`
|
||||
}
|
||||
|
||||
// Favorite marks a media item as favourite for a given user.
|
||||
// Favorite 将媒体项标记为给定用户的收藏。
|
||||
type Favorite struct {
|
||||
Base
|
||||
UserID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_media" json:"user_id"`
|
||||
MediaID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_media" json:"media_id"`
|
||||
}
|
||||
|
||||
// Playlist is a user-curated, ordered list of media items.
|
||||
// Playlist 是用户策划的、有序的媒体列表。
|
||||
type Playlist struct {
|
||||
Base
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
@@ -156,7 +157,7 @@ type Playlist struct {
|
||||
IsPublic bool `gorm:"default:false" json:"is_public"`
|
||||
}
|
||||
|
||||
// PlaylistItem is the join table between Playlists and Media with ordering.
|
||||
// PlaylistItem 是 Playlist 和 Media 的连接表,带有排序。
|
||||
type PlaylistItem struct {
|
||||
Base
|
||||
PlaylistID string `gorm:"index;size:36;not null" json:"playlist_id"`
|
||||
@@ -164,7 +165,7 @@ type PlaylistItem struct {
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
// DownloadTask is an outstanding (or completed) torrent / HTTP download.
|
||||
// DownloadTask 是待处理(或已完成)的 torrent / HTTP 下载。
|
||||
type DownloadTask struct {
|
||||
Base
|
||||
UserID string `gorm:"index;size:36" json:"user_id"`
|
||||
@@ -175,27 +176,25 @@ type DownloadTask struct {
|
||||
Progress float32 `json:"progress"`
|
||||
}
|
||||
|
||||
// Subscription is an automation rule that polls an RSS feed and queues
|
||||
// matching torrents into the configured download client.
|
||||
// Subscription 是自动化规则,轮询 RSS 源并将匹配种子排队到配置的下载客户端。
|
||||
type Subscription struct {
|
||||
Base
|
||||
UserID string `gorm:"index;size:36" json:"user_id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
FeedURL string `gorm:"size:2048;not null" json:"feed_url"`
|
||||
Filter string `gorm:"size:512" json:"filter"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
UserID string `gorm:"index;size:36" json:"user_id"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
FeedURL string `gorm:"size:2048;not null" json:"feed_url"`
|
||||
Filter string `gorm:"size:512" json:"filter"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
}
|
||||
|
||||
// Setting is a single key/value system-wide preference (used by the admin UI).
|
||||
// Setting 是单个键/值系统级偏好(供管理 UI 使用)。
|
||||
type Setting struct {
|
||||
Key string `gorm:"primaryKey;size:128" json:"key"`
|
||||
Value string `gorm:"type:text" json:"value"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AccessLog is a structured audit-trail entry. Stored in SQLite for the
|
||||
// admin Activity panel.
|
||||
// AccessLog 是结构化审计跟踪条目。存储在 SQLite 中供管理活动面板使用。
|
||||
type AccessLog struct {
|
||||
Base
|
||||
UserID string `gorm:"index;size:36" json:"user_id"`
|
||||
@@ -205,7 +204,7 @@ type AccessLog struct {
|
||||
Detail string `gorm:"type:text" json:"detail"`
|
||||
}
|
||||
|
||||
// AllModels returns the slice consumed by gorm.AutoMigrate.
|
||||
// AllModels 返回 gorm.AutoMigrate 使用的切片。
|
||||
func AllModels() []interface{} {
|
||||
return []interface{}{
|
||||
&User{},
|
||||
@@ -221,5 +220,12 @@ func AllModels() []interface{} {
|
||||
&Setting{},
|
||||
&AccessLog{},
|
||||
&APIConfig{},
|
||||
&UserPermission{},
|
||||
&RefreshToken{},
|
||||
&ApiConfig{},
|
||||
&DownloadClient{},
|
||||
&NotifyChannel{},
|
||||
&Site{},
|
||||
&STRMRecord{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Package model 定义通知渠道配置数据模型。
|
||||
package model
|
||||
|
||||
// NotifyChannel 通知渠道配置。
|
||||
// 支持多种通知渠道:telegram / wechat / bark / webhook / email。
|
||||
// Events 字段存储 JSON array,表示该渠道订阅的事件类型。
|
||||
type NotifyChannel struct {
|
||||
Base
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Type string `gorm:"size:32;not null" json:"type"` // telegram / wechat / bark / webhook / email
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Config string `gorm:"type:text" json:"-"` // JSON配置, AES加密
|
||||
Events string `gorm:"type:text" json:"events"` // 订阅的事件列表, JSON array
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Package model 定义权限相关的数据模型。
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserPermission 定义用户细粒度权限(19项)。
|
||||
// 默认开启(6项):CanViewDashboard, CanPlayMedia, CanCast, CanExternalPlayer, CanFavorite, CanViewHistory
|
||||
// 默认关闭(13项):其他权限需要管理员分配
|
||||
type UserPermission struct {
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
UserID string `gorm:"uniqueIndex;size:36;not null" json:"user_id"`
|
||||
|
||||
// 默认开启(6项)- Basic
|
||||
CanViewDashboard bool `gorm:"default:true" json:"can_view_dashboard"`
|
||||
CanPlayMedia bool `gorm:"default:true" json:"can_play_media"`
|
||||
CanCast bool `gorm:"default:true" json:"can_cast"`
|
||||
CanExternalPlayer bool `gorm:"default:true" json:"can_external_player"`
|
||||
CanFavorite bool `gorm:"default:true" json:"can_favorite"`
|
||||
CanViewHistory bool `gorm:"default:true" json:"can_view_history"`
|
||||
|
||||
// 默认关闭(13项)- Advanced
|
||||
CanEditMedia bool `gorm:"default:false" json:"can_edit_media"`
|
||||
CanRescrape bool `gorm:"default:false" json:"can_rescrape"`
|
||||
CanUseAI bool `gorm:"default:false" json:"can_use_ai"`
|
||||
CanCaptureFrames bool `gorm:"default:false" json:"can_capture_frames"`
|
||||
CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"`
|
||||
CanViewDiscover bool `gorm:"default:false" json:"can_view_discover"`
|
||||
CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"`
|
||||
CanManageSites bool `gorm:"default:false" json:"can_manage_sites"`
|
||||
CanUseAIAssistant bool `gorm:"default:false" json:"can_use_ai_assistant"`
|
||||
CanManageUsers bool `gorm:"default:false" json:"can_manage_users"`
|
||||
CanManageFiles bool `gorm:"default:false" json:"can_manage_files"`
|
||||
CanManageStrm bool `gorm:"default:false" json:"can_manage_strm"`
|
||||
CanAccessSettings bool `gorm:"default:false" json:"can_access_settings"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BeforeCreate 生成 UUID。
|
||||
func (p *UserPermission) BeforeCreate(_ *gorm.DB) error {
|
||||
if p.ID == "" {
|
||||
p.ID = uuid.NewString()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewDefaultPermission 创建带有默认权限的 UserPermission。
|
||||
func NewDefaultPermission(userID string) *UserPermission {
|
||||
return &UserPermission{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
CanViewDashboard: true,
|
||||
CanPlayMedia: true,
|
||||
CanCast: true,
|
||||
CanExternalPlayer: true,
|
||||
CanFavorite: true,
|
||||
CanViewHistory: true,
|
||||
CanEditMedia: false,
|
||||
CanRescrape: false,
|
||||
CanUseAI: false,
|
||||
CanCaptureFrames: false,
|
||||
CanManageDownloads: false,
|
||||
CanViewDiscover: false,
|
||||
CanManageSubscriptions: false,
|
||||
CanManageSites: false,
|
||||
CanUseAIAssistant: false,
|
||||
CanManageUsers: false,
|
||||
CanManageFiles: false,
|
||||
CanManageStrm: false,
|
||||
CanAccessSettings: false,
|
||||
}
|
||||
}
|
||||
|
||||
// PermissionMap 将权限结构转换为 map[string]bool 便于检查。
|
||||
func (p *UserPermission) PermissionMap() map[string]bool {
|
||||
return map[string]bool{
|
||||
"can_view_dashboard": p.CanViewDashboard,
|
||||
"can_play_media": p.CanPlayMedia,
|
||||
"can_cast": p.CanCast,
|
||||
"can_external_player": p.CanExternalPlayer,
|
||||
"can_favorite": p.CanFavorite,
|
||||
"can_view_history": p.CanViewHistory,
|
||||
"can_edit_media": p.CanEditMedia,
|
||||
"can_rescrape": p.CanRescrape,
|
||||
"can_use_ai": p.CanUseAI,
|
||||
"can_capture_frames": p.CanCaptureFrames,
|
||||
"can_manage_downloads": p.CanManageDownloads,
|
||||
"can_view_discover": p.CanViewDiscover,
|
||||
"can_manage_subscriptions": p.CanManageSubscriptions,
|
||||
"can_manage_sites": p.CanManageSites,
|
||||
"can_use_ai_assistant": p.CanUseAIAssistant,
|
||||
"can_manage_users": p.CanManageUsers,
|
||||
"can_manage_files": p.CanManageFiles,
|
||||
"can_manage_strm": p.CanManageStrm,
|
||||
"can_access_settings": p.CanAccessSettings,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package model 定义刷新令牌数据模型。
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RefreshToken 用于双令牌认证机制中的刷新令牌。
|
||||
// 存储时使用 SHA256 哈希,原始令牌不存储。
|
||||
type RefreshToken struct {
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Revoked bool `gorm:"default:false" json:"revoked"`
|
||||
}
|
||||
|
||||
// BeforeCreate 生成 UUID。
|
||||
func (t *RefreshToken) BeforeCreate(_ *gorm.DB) error {
|
||||
if t.ID == "" {
|
||||
t.ID = uuid.NewString()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsExpired 检查刷新令牌是否已过期。
|
||||
func (t *RefreshToken) IsExpired() bool {
|
||||
return time.Now().After(t.ExpiresAt)
|
||||
}
|
||||
|
||||
// IsValid 检查刷新令牌是否有效(未撤销且未过期)。
|
||||
func (t *RefreshToken) IsValid() bool {
|
||||
return !t.Revoked && !t.IsExpired()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Package model — PT 站点配置数据模型。
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Site PT 站点配置。
|
||||
type Site struct {
|
||||
Base
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Type string `gorm:"size:32;not null" json:"type"` // nexusphp / gazelle / unit3d / mteam / discuz / custom_rss
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
AuthType string `gorm:"size:32;not null" json:"auth_type"` // cookie / api_key / auth_header
|
||||
Cookie string `gorm:"type:text" json:"-"` // AES 加密
|
||||
APIKey string `gorm:"type:text" json:"-"` // AES 加密
|
||||
AuthHeader string `gorm:"type:text" json:"-"` // AES 加密
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
Extra string `gorm:"type:text" json:"-"` // JSON 扩展配置, AES 加密
|
||||
LastError string `gorm:"size:1024" json:"last_error"`
|
||||
LastCheckAt *time.Time `json:"last_check_at"`
|
||||
}
|
||||
|
||||
// SiteType 返回支持的站点类型列表。
|
||||
func SiteTypes() []string {
|
||||
return []string{"nexusphp", "gazelle", "unit3d", "mteam", "discuz", "custom_rss"}
|
||||
}
|
||||
|
||||
// AuthTypes 返回支持的认证方式列表。
|
||||
func AuthTypes() []string {
|
||||
return []string{"cookie", "api_key", "auth_header"}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package model — STRM 文件记录数据模型。
|
||||
package model
|
||||
|
||||
// STRMRecord STRM 文件记录。
|
||||
// 外部存储以"文件"形式入库,URL 指向实际资源。
|
||||
type STRMRecord struct {
|
||||
Base
|
||||
Title string `gorm:"size:512;not null;index" json:"title"`
|
||||
URL string `gorm:"size:2048;not null" json:"url"` // STRM 文件指向的 URL
|
||||
FilePath string `gorm:"size:1024;not null" json:"file_path"` // 本地 STRM 文件路径
|
||||
Protocol string `gorm:"size:32;not null" json:"protocol"` // webdav / alist / s3 / http / https
|
||||
FileSize int64 `json:"file_size"`
|
||||
MediaID string `gorm:"size:36;index" json:"media_id"` // 关联媒体 ID
|
||||
MediaType string `gorm:"size:16" json:"media_type"` // movie / series
|
||||
SeasonNum int `json:"season_num"`
|
||||
EpisodeNum int `json:"episode_num"`
|
||||
}
|
||||
|
||||
// AllowedSTRMProtocols 协议白名单。
|
||||
var AllowedSTRMProtocols = []string{
|
||||
"webdav", "davs",
|
||||
"alist", "alists",
|
||||
"s3",
|
||||
"http", "https",
|
||||
}
|
||||
|
||||
// IsAllowedProtocol 检查协议是否在白名单中。
|
||||
func IsAllowedProtocol(protocol string) bool {
|
||||
for _, p := range AllowedSTRMProtocols {
|
||||
if p == protocol {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Package repository 实现下载客户端配置的数据访问层。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
// DownloadClientRepository persists model.DownloadClient records.
|
||||
type DownloadClientRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new download client.
|
||||
func (r *DownloadClientRepository) Create(ctx context.Context, c *model.DownloadClient) error {
|
||||
return r.db.WithContext(ctx).Create(c).Error
|
||||
}
|
||||
|
||||
// FindByID returns the download client by ID, or (nil, nil) when absent.
|
||||
func (r *DownloadClientRepository) FindByID(ctx context.Context, id string) (*model.DownloadClient, error) {
|
||||
var c model.DownloadClient
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&c).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// FindDefault returns the default download client, or (nil, nil).
|
||||
func (r *DownloadClientRepository) FindDefault(ctx context.Context) (*model.DownloadClient, error) {
|
||||
var c model.DownloadClient
|
||||
err := r.db.WithContext(ctx).Where("is_default = ? AND enabled = ?", true, true).First(&c).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// List returns all download clients ordered by creation time.
|
||||
func (r *DownloadClientRepository) List(ctx context.Context) ([]model.DownloadClient, error) {
|
||||
var rows []model.DownloadClient
|
||||
err := r.db.WithContext(ctx).Order("created_at asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListEnabled returns all enabled download clients.
|
||||
func (r *DownloadClientRepository) ListEnabled(ctx context.Context) ([]model.DownloadClient, error) {
|
||||
var rows []model.DownloadClient
|
||||
err := r.db.WithContext(ctx).Where("enabled = ?", true).Order("created_at asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// Update persists changes to a download client.
|
||||
func (r *DownloadClientRepository) Update(ctx context.Context, c *model.DownloadClient) error {
|
||||
return r.db.WithContext(ctx).Save(c).Error
|
||||
}
|
||||
|
||||
// Delete removes a download client (soft-delete).
|
||||
func (r *DownloadClientRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.DownloadClient{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// ClearDefault unsets the default flag for all clients.
|
||||
func (r *DownloadClientRepository) ClearDefault(ctx context.Context) error {
|
||||
return r.db.WithContext(ctx).Model(&model.DownloadClient{}).
|
||||
Where("is_default = ?", true).Update("is_default", false).Error
|
||||
}
|
||||
|
||||
// SetDefault sets a specific client as default and clears others.
|
||||
func (r *DownloadClientRepository) SetDefault(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.DownloadClient{}).
|
||||
Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.DownloadClient{}).
|
||||
Where("id = ?", id).Updates(map[string]any{
|
||||
"is_default": true,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package repository 实现通知渠道配置的数据访问层。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
// NotifyChannelRepository persists model.NotifyChannel records.
|
||||
type NotifyChannelRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new notification channel.
|
||||
func (r *NotifyChannelRepository) Create(ctx context.Context, c *model.NotifyChannel) error {
|
||||
return r.db.WithContext(ctx).Create(c).Error
|
||||
}
|
||||
|
||||
// FindByID returns the notification channel by ID, or (nil, nil) when absent.
|
||||
func (r *NotifyChannelRepository) FindByID(ctx context.Context, id string) (*model.NotifyChannel, error) {
|
||||
var c model.NotifyChannel
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&c).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// List returns all notification channels ordered by creation time.
|
||||
func (r *NotifyChannelRepository) List(ctx context.Context) ([]model.NotifyChannel, error) {
|
||||
var rows []model.NotifyChannel
|
||||
err := r.db.WithContext(ctx).Order("created_at asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListEnabled returns all enabled notification channels.
|
||||
func (r *NotifyChannelRepository) ListEnabled(ctx context.Context) ([]model.NotifyChannel, error) {
|
||||
var rows []model.NotifyChannel
|
||||
err := r.db.WithContext(ctx).Where("enabled = ?", true).Order("created_at asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListByEvent returns all enabled channels that subscribe to the given event type.
|
||||
func (r *NotifyChannelRepository) ListByEvent(ctx context.Context, eventType string) ([]model.NotifyChannel, error) {
|
||||
var rows []model.NotifyChannel
|
||||
// Events is a JSON array stored as text; use LIKE for simple matching.
|
||||
// This works for exact event type matches within the JSON array.
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("enabled = ? AND events LIKE ?", true, "%\""+eventType+"\"%").
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// Update persists changes to a notification channel.
|
||||
func (r *NotifyChannelRepository) Update(ctx context.Context, c *model.NotifyChannel) error {
|
||||
return r.db.WithContext(ctx).Save(c).Error
|
||||
}
|
||||
|
||||
// Delete removes a notification channel (soft-delete).
|
||||
func (r *NotifyChannelRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.NotifyChannel{}, "id = ?", id).Error
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
// Package repository implements a thin GORM-based data-access layer over the
|
||||
// types declared in internal/model. Each method takes a context.Context so we
|
||||
// can plug in cancellation / tracing later.
|
||||
// Package repository 实现基于 GORM 的数据访问层。
|
||||
// 每个方法接受 context.Context 以便后续插入取消/追踪。
|
||||
//
|
||||
// Repositories are intentionally narrow: they only know how to persist data,
|
||||
// not how to interpret it. Domain logic lives in internal/service.
|
||||
// Repository 故意保持精简:它们只负责持久化数据,不处理业务逻辑。
|
||||
// 业务逻辑位于 internal/service。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
// Container is the registry of all repositories injected into services.
|
||||
// Container 是所有 repositories 的注册表,注入到 services 中。
|
||||
type Container struct {
|
||||
DB *gorm.DB
|
||||
User *UserRepository
|
||||
@@ -30,9 +31,16 @@ type Container struct {
|
||||
Subscription *SubscriptionRepository
|
||||
Setting *SettingRepository
|
||||
Log *AccessLogRepository
|
||||
Permission *PermissionRepository
|
||||
RefreshToken *RefreshTokenRepository
|
||||
ApiConfig *ApiConfigRepository
|
||||
DownloadClient *DownloadClientRepository
|
||||
NotifyChannel *NotifyChannelRepository
|
||||
Site *SiteRepository
|
||||
STRM *STRMRepository
|
||||
}
|
||||
|
||||
// New wires every repository to a single *gorm.DB.
|
||||
// New 将每个 repository 连接到单个 *gorm.DB。
|
||||
func New(db *gorm.DB) *Container {
|
||||
return &Container{
|
||||
DB: db,
|
||||
@@ -47,6 +55,13 @@ func New(db *gorm.DB) *Container {
|
||||
Subscription: &SubscriptionRepository{db: db},
|
||||
Setting: &SettingRepository{db: db},
|
||||
Log: &AccessLogRepository{db: db},
|
||||
Permission: &PermissionRepository{db: db},
|
||||
RefreshToken: &RefreshTokenRepository{db: db},
|
||||
ApiConfig: &ApiConfigRepository{db: db},
|
||||
DownloadClient: &DownloadClientRepository{db: db},
|
||||
NotifyChannel: &NotifyChannelRepository{db: db},
|
||||
Site: &SiteRepository{db: db},
|
||||
STRM: &STRMRepository{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +282,7 @@ func (r *HistoryRepository) ListByUser(ctx context.Context, userID string, limit
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ─── Favorite ────────────────────────────────────────────────────────────────
|
||||
// ─── Favorite ───────────────────────────────────────────────────────────────
|
||||
|
||||
// FavoriteRepository persists model.Favorite records.
|
||||
type FavoriteRepository struct{ db *gorm.DB }
|
||||
@@ -311,7 +326,7 @@ func (r *PlaylistRepository) ListByUser(ctx context.Context, userID string) ([]m
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ─── Download ────────────────────────────────────────────────────────────────
|
||||
// ─── Download ───────────────────────────────────────────────────────────────
|
||||
|
||||
// DownloadRepository persists model.DownloadTask records.
|
||||
type DownloadRepository struct{ db *gorm.DB }
|
||||
@@ -328,7 +343,7 @@ func (r *DownloadRepository) List(ctx context.Context) ([]model.DownloadTask, er
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ─── Subscription ────────────────────────────────────────────────────────────
|
||||
// ─── Subscription ───────────────────────────────────────────────────────────
|
||||
|
||||
// SubscriptionRepository persists model.Subscription records.
|
||||
type SubscriptionRepository struct{ db *gorm.DB }
|
||||
@@ -389,3 +404,158 @@ func (r *AccessLogRepository) Recent(ctx context.Context, limit int) ([]model.Ac
|
||||
err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ─── Permission ──────────────────────────────────────────────────────────────
|
||||
|
||||
// PermissionRepository persists model.UserPermission records.
|
||||
type PermissionRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new permission record.
|
||||
func (r *PermissionRepository) Create(ctx context.Context, p *model.UserPermission) error {
|
||||
return r.db.WithContext(ctx).Create(p).Error
|
||||
}
|
||||
|
||||
// FindByUserID returns the permission record for a user, or (nil, nil) when absent.
|
||||
func (r *PermissionRepository) FindByUserID(ctx context.Context, userID string) (*model.UserPermission, error) {
|
||||
var p model.UserPermission
|
||||
err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&p).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Update updates permission fields for a user.
|
||||
func (r *PermissionRepository) Update(ctx context.Context, userID string, updates map[string]bool) error {
|
||||
return r.db.WithContext(ctx).Model(&model.UserPermission{}).
|
||||
Where("user_id = ?", userID).Updates(updates).Error
|
||||
}
|
||||
|
||||
// Upsert creates or updates a permission record.
|
||||
func (r *PermissionRepository) Upsert(ctx context.Context, p *model.UserPermission) error {
|
||||
return r.db.WithContext(ctx).Where("user_id = ?", p.UserID).
|
||||
Assign(*p).FirstOrCreate(p).Error
|
||||
}
|
||||
|
||||
// Delete removes a permission record.
|
||||
func (r *PermissionRepository) Delete(ctx context.Context, userID string) error {
|
||||
return r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&model.UserPermission{}).Error
|
||||
}
|
||||
|
||||
// ─── Refresh Token ───────────────────────────────────────────────────────────
|
||||
|
||||
// RefreshTokenRepository persists model.RefreshToken records.
|
||||
type RefreshTokenRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new refresh token record.
|
||||
func (r *RefreshTokenRepository) Create(ctx context.Context, t *model.RefreshToken) error {
|
||||
return r.db.WithContext(ctx).Create(t).Error
|
||||
}
|
||||
|
||||
// FindByHash returns the refresh token matching the hash, or (nil, nil).
|
||||
func (r *RefreshTokenRepository) FindByHash(ctx context.Context, hash string) (*model.RefreshToken, error) {
|
||||
var t model.RefreshToken
|
||||
err := r.db.WithContext(ctx).Where("token_hash = ?", hash).First(&t).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// RevokeByUserID revokes all refresh tokens for a user.
|
||||
func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.RefreshToken{}).
|
||||
Where("user_id = ?", userID).Update("revoked", true).Error
|
||||
}
|
||||
|
||||
// DeleteExpired removes all expired refresh tokens.
|
||||
func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error {
|
||||
return r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&model.RefreshToken{}).Error
|
||||
}
|
||||
|
||||
// Revoke revokes a specific refresh token.
|
||||
func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.RefreshToken{}).
|
||||
Where("token_hash = ?", hash).Update("revoked", true).Error
|
||||
}
|
||||
|
||||
// HashToken returns the SHA256 hash of a token.
|
||||
func HashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// ─── API Config ──────────────────────────────────────────────────────────────
|
||||
|
||||
// ApiConfigRepository persists model.ApiConfig records.
|
||||
type ApiConfigRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new API config record.
|
||||
func (r *ApiConfigRepository) Create(ctx context.Context, c *model.ApiConfig) error {
|
||||
return r.db.WithContext(ctx).Create(c).Error
|
||||
}
|
||||
|
||||
// FindByProvider returns the API config for a provider, or (nil, nil).
|
||||
func (r *ApiConfigRepository) FindByProvider(ctx context.Context, provider string) (*model.ApiConfig, error) {
|
||||
var c model.ApiConfig
|
||||
err := r.db.WithContext(ctx).Where("provider = ?", provider).First(&c).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// List returns all API configs.
|
||||
func (r *ApiConfigRepository) List(ctx context.Context) ([]model.ApiConfig, error) {
|
||||
var rows []model.ApiConfig
|
||||
err := r.db.WithContext(ctx).Order("provider asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// Upsert creates or updates an API config.
|
||||
func (r *ApiConfigRepository) Upsert(ctx context.Context, c *model.ApiConfig) error {
|
||||
return r.db.WithContext(ctx).Where("provider = ?", c.Provider).
|
||||
Assign(model.ApiConfig{
|
||||
APIKey: c.APIKey,
|
||||
BaseURL: c.BaseURL,
|
||||
Extra: c.Extra,
|
||||
Enabled: c.Enabled,
|
||||
UpdatedAt: time.Now(),
|
||||
}).FirstOrCreate(c).Error
|
||||
}
|
||||
|
||||
// Update updates an API config.
|
||||
func (r *ApiConfigRepository) Update(ctx context.Context, c *model.ApiConfig) error {
|
||||
return r.db.WithContext(ctx).Model(&model.ApiConfig{}).
|
||||
Where("provider = ?", c.Provider).Updates(map[string]any{
|
||||
"api_key": c.APIKey,
|
||||
"base_url": c.BaseURL,
|
||||
"extra": c.Extra,
|
||||
"enabled": c.Enabled,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// Delete removes an API config.
|
||||
func (r *ApiConfigRepository) Delete(ctx context.Context, provider string) error {
|
||||
return r.db.WithContext(ctx).Where("provider = ?", provider).Delete(&model.ApiConfig{}).Error
|
||||
}
|
||||
|
||||
// UpdateTestResult 更新测试结果。
|
||||
func (r *ApiConfigRepository) UpdateTestResult(ctx context.Context, provider, result string) error {
|
||||
now := time.Now()
|
||||
return r.db.WithContext(ctx).Model(&model.ApiConfig{}).
|
||||
Where("provider = ?", provider).Updates(map[string]any{
|
||||
"test_result": result,
|
||||
"last_tested_at": &now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package repository — PT 站点数据访问层。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
// SiteRepository persists model.Site records.
|
||||
type SiteRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new site.
|
||||
func (r *SiteRepository) Create(ctx context.Context, s *model.Site) error {
|
||||
return r.db.WithContext(ctx).Create(s).Error
|
||||
}
|
||||
|
||||
// FindByID returns the site by ID, or (nil, nil) when absent.
|
||||
func (r *SiteRepository) FindByID(ctx context.Context, id string) (*model.Site, error) {
|
||||
var s model.Site
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// List returns all sites ordered by name.
|
||||
func (r *SiteRepository) List(ctx context.Context) ([]model.Site, error) {
|
||||
var rows []model.Site
|
||||
err := r.db.WithContext(ctx).Order("name asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListEnabled returns all enabled sites.
|
||||
func (r *SiteRepository) ListEnabled(ctx context.Context) ([]model.Site, error) {
|
||||
var rows []model.Site
|
||||
err := r.db.WithContext(ctx).Where("enabled = ?", true).Order("name asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// Update updates site fields.
|
||||
func (r *SiteRepository) Update(ctx context.Context, s *model.Site) error {
|
||||
return r.db.WithContext(ctx).Save(s).Error
|
||||
}
|
||||
|
||||
// Delete removes a site (soft-delete).
|
||||
func (r *SiteRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.Site{}, "id = ?", id).Error
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package repository — STRM 文件记录数据访问层。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
// STRMRepository persists model.STRMRecord records.
|
||||
type STRMRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new STRM record.
|
||||
func (r *STRMRepository) Create(ctx context.Context, s *model.STRMRecord) error {
|
||||
return r.db.WithContext(ctx).Create(s).Error
|
||||
}
|
||||
|
||||
// CreateBatch inserts multiple STRM records.
|
||||
func (r *STRMRepository) CreateBatch(ctx context.Context, records []model.STRMRecord) error {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.WithContext(ctx).CreateInBatches(records, 100).Error
|
||||
}
|
||||
|
||||
// FindByID returns the STRM record by ID, or (nil, nil) when absent.
|
||||
func (r *STRMRepository) FindByID(ctx context.Context, id string) (*model.STRMRecord, error) {
|
||||
var s model.STRMRecord
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// List returns STRM records with optional filters. Supports pagination.
|
||||
// Filters: media_id, media_type, protocol
|
||||
func (r *STRMRepository) List(ctx context.Context, filters map[string]string, offset, limit int) ([]model.STRMRecord, int64, error) {
|
||||
q := r.db.WithContext(ctx).Model(&model.STRMRecord{})
|
||||
|
||||
if mediaID, ok := filters["media_id"]; ok && mediaID != "" {
|
||||
q = q.Where("media_id = ?", mediaID)
|
||||
}
|
||||
if mediaType, ok := filters["media_type"]; ok && mediaType != "" {
|
||||
q = q.Where("media_type = ?", mediaType)
|
||||
}
|
||||
if protocol, ok := filters["protocol"]; ok && protocol != "" {
|
||||
q = q.Where("protocol = ?", protocol)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var rows []model.STRMRecord
|
||||
err := q.Order("created_at desc").Offset(offset).Limit(limit).Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
// Update updates a STRM record.
|
||||
func (r *STRMRepository) Update(ctx context.Context, s *model.STRMRecord) error {
|
||||
return r.db.WithContext(ctx).Save(s).Error
|
||||
}
|
||||
|
||||
// Delete removes a STRM record (soft-delete).
|
||||
func (r *STRMRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.STRMRecord{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// FindByMediaID returns STRM records for a given media ID.
|
||||
func (r *STRMRepository) FindByMediaID(ctx context.Context, mediaID string) ([]model.STRMRecord, error) {
|
||||
var rows []model.STRMRecord
|
||||
err := r.db.WithContext(ctx).Where("media_id = ?", mediaID).Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
// Package service — API 配置管理服务。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// ApiConfigService 负责第三方 API 配置的 CRUD 和加密管理。
|
||||
type ApiConfigService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
crypto *CryptoService
|
||||
}
|
||||
|
||||
// NewApiConfigService 创建 API 配置服务实例。
|
||||
func NewApiConfigService(cfg *config.Config, log *zap.Logger, repo *repository.Container, crypto *CryptoService) *ApiConfigService {
|
||||
return &ApiConfigService{cfg: cfg, log: log, repo: repo, crypto: crypto}
|
||||
}
|
||||
|
||||
// ApiConfigService 错误定义。
|
||||
var (
|
||||
ErrApiConfigNotFound = errors.New("API configuration not found")
|
||||
ErrInvalidProvider = errors.New("invalid provider")
|
||||
ErrTestFailed = errors.New("connection test failed")
|
||||
)
|
||||
|
||||
// GetByProvider 获取指定提供者的 API 配置。
|
||||
func (s *ApiConfigService) GetByProvider(ctx context.Context, provider string) (*model.ApiConfig, error) {
|
||||
cfg, err := s.repo.ApiConfig.FindByProvider(ctx, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, ErrApiConfigNotFound
|
||||
}
|
||||
// 解密敏感字段
|
||||
if cfg.APIKey != "" && s.crypto.IsEncrypted(cfg.APIKey) {
|
||||
cfg.APIKey = s.crypto.Decrypt(cfg.APIKey)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// List 返回所有 API 配置。
|
||||
func (s *ApiConfigService) List(ctx context.Context) ([]model.ApiConfig, error) {
|
||||
configs, err := s.repo.ApiConfig.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 解密敏感字段
|
||||
for i := range configs {
|
||||
if configs[i].APIKey != "" && s.crypto.IsEncrypted(configs[i].APIKey) {
|
||||
configs[i].APIKey = s.crypto.Decrypt(configs[i].APIKey)
|
||||
}
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// GetProviders 返回预定义的提供者列表。
|
||||
func (s *ApiConfigService) GetProviders() []model.ApiProvider {
|
||||
return model.PredefinedProviders()
|
||||
}
|
||||
|
||||
// Upsert 创建或更新 API 配置,自动加密敏感字段。
|
||||
func (s *ApiConfigService) Upsert(ctx context.Context, provider string, apiKey, baseURL, extra string, enabled bool) (*model.ApiConfig, error) {
|
||||
// 验证提供者是否有效
|
||||
if !s.isValidProvider(provider) {
|
||||
return nil, ErrInvalidProvider
|
||||
}
|
||||
|
||||
// 加密 API Key
|
||||
encryptedKey := apiKey
|
||||
if apiKey != "" && !s.crypto.IsEncrypted(apiKey) {
|
||||
encryptedKey = s.crypto.Encrypt(apiKey)
|
||||
}
|
||||
|
||||
cfg := &model.ApiConfig{
|
||||
Provider: provider,
|
||||
APIKey: encryptedKey,
|
||||
BaseURL: baseURL,
|
||||
Extra: extra,
|
||||
Enabled: enabled,
|
||||
Description: s.getProviderDescription(provider),
|
||||
}
|
||||
|
||||
if err := s.repo.ApiConfig.Upsert(ctx, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 返回解密后的配置
|
||||
cfg.APIKey = apiKey
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Delete 删除 API 配置。
|
||||
func (s *ApiConfigService) Delete(ctx context.Context, provider string) error {
|
||||
return s.repo.ApiConfig.Delete(ctx, provider)
|
||||
}
|
||||
|
||||
// Update 更新 API 配置。
|
||||
func (s *ApiConfigService) Update(ctx context.Context, provider string, apiKey, baseURL, extra string, enabled bool) error {
|
||||
// 加密 API Key
|
||||
encryptedKey := apiKey
|
||||
if apiKey != "" && !s.crypto.IsEncrypted(apiKey) {
|
||||
encryptedKey = s.crypto.Encrypt(apiKey)
|
||||
}
|
||||
|
||||
cfg := &model.ApiConfig{
|
||||
Provider: provider,
|
||||
APIKey: encryptedKey,
|
||||
BaseURL: baseURL,
|
||||
Extra: extra,
|
||||
Enabled: enabled,
|
||||
}
|
||||
|
||||
return s.repo.ApiConfig.Update(ctx, cfg)
|
||||
}
|
||||
|
||||
// TestConnection 测试 API 连接。
|
||||
func (s *ApiConfigService) TestConnection(ctx context.Context, provider string) (string, error) {
|
||||
cfg, err := s.GetByProvider(ctx, provider)
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
|
||||
// 根据不同提供者执行不同的测试逻辑
|
||||
switch provider {
|
||||
case "tmdb":
|
||||
return s.testTMDb(cfg)
|
||||
case "openai":
|
||||
return s.testOpenAI(cfg)
|
||||
case "deepseek":
|
||||
return s.testDeepSeek(cfg)
|
||||
case "siliconflow":
|
||||
return s.testSiliconFlow(cfg)
|
||||
default:
|
||||
return "unknown", fmt.Errorf("no test implemented for provider: %s", provider)
|
||||
}
|
||||
}
|
||||
|
||||
// testTMDb 测试 TMDb API 连接。
|
||||
func (s *ApiConfigService) testTMDb(cfg *model.ApiConfig) (string, error) {
|
||||
if cfg.APIKey == "" {
|
||||
return "error", errors.New("API key is required")
|
||||
}
|
||||
|
||||
testURL := "https://api.themoviedb.org/3/configuration?api_key=" + cfg.APIKey
|
||||
resp, err := http.Get(testURL)
|
||||
if err != nil {
|
||||
// 如果配置了代理,使用代理
|
||||
if s.cfg.Secrets.TMDbAPIProxy != "" {
|
||||
proxyURL := s.cfg.Secrets.TMDbAPIProxy + "?api_key=" + cfg.APIKey
|
||||
resp, err = http.Get(proxyURL)
|
||||
if err != nil {
|
||||
return "error", fmt.Errorf("TMDb connection failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return "success", nil
|
||||
}
|
||||
return "error", fmt.Errorf("TMDb API returned status %d", resp.StatusCode)
|
||||
}
|
||||
return "error", fmt.Errorf("TMDb connection failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
return "success", nil
|
||||
}
|
||||
if resp.StatusCode == 401 {
|
||||
return "invalid", errors.New("invalid API key")
|
||||
}
|
||||
return "error", fmt.Errorf("TMDb API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// testOpenAI 测试 OpenAI API 连接。
|
||||
func (s *ApiConfigService) testOpenAI(cfg *model.ApiConfig) (string, error) {
|
||||
if cfg.APIKey == "" {
|
||||
return "error", errors.New("API key is required")
|
||||
}
|
||||
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
testURL := baseURL + "/models"
|
||||
req, err := http.NewRequest("GET", testURL, nil)
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req.WithContext(context.Background()))
|
||||
if err != nil {
|
||||
return "error", fmt.Errorf("OpenAI connection failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
return "success", nil
|
||||
}
|
||||
if resp.StatusCode == 401 {
|
||||
return "invalid", errors.New("invalid API key")
|
||||
}
|
||||
return "error", fmt.Errorf("OpenAI API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// testDeepSeek 测试 DeepSeek API 连接。
|
||||
func (s *ApiConfigService) testDeepSeek(cfg *model.ApiConfig) (string, error) {
|
||||
if cfg.APIKey == "" {
|
||||
return "error", errors.New("API key is required")
|
||||
}
|
||||
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.deepseek.com"
|
||||
}
|
||||
|
||||
testURL := baseURL + "/models"
|
||||
req, err := http.NewRequest("GET", testURL, nil)
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req.WithContext(context.Background()))
|
||||
if err != nil {
|
||||
return "error", fmt.Errorf("DeepSeek connection failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
return "success", nil
|
||||
}
|
||||
if resp.StatusCode == 401 {
|
||||
return "invalid", errors.New("invalid API key")
|
||||
}
|
||||
return "error", fmt.Errorf("DeepSeek API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// testSiliconFlow 测试 SiliconFlow API 连接。
|
||||
func (s *ApiConfigService) testSiliconFlow(cfg *model.ApiConfig) (string, error) {
|
||||
if cfg.APIKey == "" {
|
||||
return "error", errors.New("API key is required")
|
||||
}
|
||||
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.siliconflow.cn/v1"
|
||||
}
|
||||
|
||||
testURL := baseURL + "/models"
|
||||
req, err := http.NewRequest("GET", testURL, nil)
|
||||
if err != nil {
|
||||
return "error", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req.WithContext(context.Background()))
|
||||
if err != nil {
|
||||
return "error", fmt.Errorf("SiliconFlow connection failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
return "success", nil
|
||||
}
|
||||
if resp.StatusCode == 401 {
|
||||
return "invalid", errors.New("invalid API key")
|
||||
}
|
||||
return "error", fmt.Errorf("SiliconFlow API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// GetEffectiveConfig 获取生效的 API 配置(数据库配置优先于配置文件)。
|
||||
func (s *ApiConfigService) GetEffectiveConfig(ctx context.Context, provider string) (*model.ApiConfig, error) {
|
||||
// 首先尝试从数据库获取
|
||||
cfg, err := s.GetByProvider(ctx, provider)
|
||||
if err == nil && cfg != nil {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// 如果数据库没有,尝试从配置文件获取
|
||||
return s.getConfigFromFile(provider)
|
||||
}
|
||||
|
||||
// getConfigFromFile 从配置文件获取 API 配置。
|
||||
func (s *ApiConfigService) getConfigFromFile(provider string) (*model.ApiConfig, error) {
|
||||
var apiKey string
|
||||
var hasKey bool
|
||||
|
||||
switch provider {
|
||||
case "tmdb":
|
||||
apiKey = s.cfg.Secrets.TMDbAPIKey
|
||||
hasKey = apiKey != ""
|
||||
case "bangumi":
|
||||
apiKey = s.cfg.Secrets.BangumiToken
|
||||
hasKey = apiKey != ""
|
||||
case "thetvdb":
|
||||
apiKey = s.cfg.Secrets.TheTVDBAPIKey
|
||||
hasKey = apiKey != ""
|
||||
case "fanart":
|
||||
apiKey = s.cfg.Secrets.FanartAPIKey
|
||||
hasKey = apiKey != ""
|
||||
}
|
||||
|
||||
if !hasKey {
|
||||
return nil, ErrApiConfigNotFound
|
||||
}
|
||||
|
||||
return &model.ApiConfig{
|
||||
Provider: provider,
|
||||
APIKey: apiKey,
|
||||
Enabled: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isValidProvider 检查提供者是否有效。
|
||||
func (s *ApiConfigService) isValidProvider(provider string) bool {
|
||||
providers := model.PredefinedProviders()
|
||||
for _, p := range providers {
|
||||
if p.ID == provider {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getProviderDescription 获取提供者描述。
|
||||
func (s *ApiConfigService) getProviderDescription(provider string) string {
|
||||
providers := model.PredefinedProviders()
|
||||
for _, p := range providers {
|
||||
if p.ID == provider {
|
||||
return p.Description
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UpdateTestResult 更新测试结果。
|
||||
func (s *ApiConfigService) UpdateTestResult(ctx context.Context, provider, result string) error {
|
||||
return s.repo.ApiConfig.UpdateTestResult(ctx, provider, result)
|
||||
}
|
||||
|
||||
// MaskAPIKey 遮蔽 API Key 的中间部分。
|
||||
func (s *ApiConfigService) MaskAPIKey(apiKey string) string {
|
||||
if len(apiKey) <= 8 {
|
||||
return "***"
|
||||
}
|
||||
return apiKey[:4] + "..." + apiKey[len(apiKey)-4:]
|
||||
}
|
||||
|
||||
// ExtractBaseURL 从 URL 中提取域名。
|
||||
func ExtractBaseURL(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
// ProviderMatches 检查请求的提供者是否与配置的提供者匹配。
|
||||
func ProviderMatches(requested, configured string) bool {
|
||||
return strings.EqualFold(requested, configured)
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Package service — Aria2 下载适配器。
|
||||
//
|
||||
// Aria2Adapter 实现了 DownloadAdapter 接口,通过 Aria2 JSON-RPC API
|
||||
// 管理下载任务。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// aria2Request 是 Aria2 JSON-RPC 请求结构。
|
||||
type aria2Request struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
ID string `json:"id"`
|
||||
Params []interface{} `json:"params"`
|
||||
}
|
||||
|
||||
// aria2Response 是 Aria2 JSON-RPC 响应结构。
|
||||
type aria2Response struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID string `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *aria2Error `json:"error"`
|
||||
}
|
||||
|
||||
// aria2Error 是 Aria2 JSON-RPC 错误结构。
|
||||
type aria2Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Aria2Adapter 是 Aria2 的 DownloadAdapter 实现。
|
||||
type Aria2Adapter struct {
|
||||
mu sync.Mutex
|
||||
cfg DownloadClientConfig
|
||||
client *http.Client
|
||||
idSeq int
|
||||
}
|
||||
|
||||
// NewAria2Adapter 创建新的 Aria2 适配器。
|
||||
func NewAria2Adapter() *Aria2Adapter {
|
||||
return &Aria2Adapter{
|
||||
client: &http.Client{Timeout: 20 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize 配置并初始化 Aria2 RPC 连接。
|
||||
func (a *Aria2Adapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.cfg = cfg
|
||||
a.idSeq = 0
|
||||
return a.getVersionLocked(ctx)
|
||||
}
|
||||
|
||||
// Ping 测试连接。
|
||||
func (a *Aria2Adapter) Ping(ctx context.Context) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.getVersionLocked(ctx)
|
||||
}
|
||||
|
||||
// getVersionLocked 内部版本检查(调用者必须持有锁)。
|
||||
func (a *Aria2Adapter) getVersionLocked(ctx context.Context) error {
|
||||
rpcURL := a.cfg.Host
|
||||
if !strings.HasSuffix(rpcURL, "/jsonrpc") {
|
||||
rpcURL = strings.TrimRight(rpcURL, "/") + "/jsonrpc"
|
||||
}
|
||||
|
||||
req := &aria2Request{
|
||||
JSONRPC: "2.0",
|
||||
Method: "aria2.getVersion",
|
||||
ID: a.nextID(),
|
||||
Params: []interface{}{"token:" + a.cfg.Password},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if a.cfg.Username != "" {
|
||||
httpReq.SetBasicAuth(a.cfg.Username, a.cfg.Password)
|
||||
}
|
||||
|
||||
resp, err := a.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("aria2 rpc: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rpcLocked 发送 JSON-RPC 请求(调用者必须持有锁)。
|
||||
func (a *Aria2Adapter) rpcLocked(ctx context.Context, method string, params []interface{}) (json.RawMessage, error) {
|
||||
rpcURL := a.cfg.Host
|
||||
if !strings.HasSuffix(rpcURL, "/jsonrpc") {
|
||||
rpcURL = strings.TrimRight(rpcURL, "/") + "/jsonrpc"
|
||||
}
|
||||
|
||||
if params == nil {
|
||||
params = []interface{}{}
|
||||
}
|
||||
|
||||
// 如果 secret 不在 params 中,添加到第一位
|
||||
if len(params) > 0 {
|
||||
if secret, ok := params[0].(string); ok && strings.HasPrefix(secret, "token:") {
|
||||
// 已经有 secret
|
||||
} else {
|
||||
newParams := make([]interface{}, 0, len(params)+1)
|
||||
newParams = append(newParams, "token:"+a.cfg.Password)
|
||||
newParams = append(newParams, params...)
|
||||
params = newParams
|
||||
}
|
||||
} else {
|
||||
params = []interface{}{"token:" + a.cfg.Password}
|
||||
}
|
||||
|
||||
req := &aria2Request{
|
||||
JSONRPC: "2.0",
|
||||
Method: method,
|
||||
ID: a.nextID(),
|
||||
Params: params,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if a.cfg.Username != "" {
|
||||
httpReq.SetBasicAuth(a.cfg.Username, a.cfg.Password)
|
||||
}
|
||||
|
||||
resp, err := a.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rpcResp aria2Response
|
||||
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rpcResp.Error != nil {
|
||||
return nil, fmt.Errorf("aria2 rpc error [%d]: %s", rpcResp.Error.Code, rpcResp.Error.Message)
|
||||
}
|
||||
return rpcResp.Result, nil
|
||||
}
|
||||
|
||||
// AddTorrent 通过 URL 添加种子或磁力链接。
|
||||
func (a *Aria2Adapter) AddTorrent(ctx context.Context, torrentURL, savePath string) (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
// Aria2 addUri 的参数: [secret, [uris], options]
|
||||
uris := []string{torrentURL}
|
||||
options := map[string]string{}
|
||||
if savePath != "" {
|
||||
options["dir"] = savePath
|
||||
}
|
||||
|
||||
result, err := a.rpcLocked(ctx, "aria2.addUri", []interface{}{uris, options})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var gid string
|
||||
if err := json.Unmarshal(result, &gid); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return gid, nil
|
||||
}
|
||||
|
||||
// AddMagnet 通过磁力链接添加下载。
|
||||
func (a *Aria2Adapter) AddMagnet(ctx context.Context, magnet, savePath string) (string, error) {
|
||||
return a.AddTorrent(ctx, magnet, savePath)
|
||||
}
|
||||
|
||||
// Pause 暂停下载任务(通过 GID)。
|
||||
func (a *Aria2Adapter) Pause(ctx context.Context, hash string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
_, err := a.rpcLocked(ctx, "aria2.pause", []interface{}{hash})
|
||||
return err
|
||||
}
|
||||
|
||||
// Resume 恢复下载任务(通过 GID)。
|
||||
func (a *Aria2Adapter) Resume(ctx context.Context, hash string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
_, err := a.rpcLocked(ctx, "aria2.unpause", []interface{}{hash})
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove 移除下载任务。
|
||||
func (a *Aria2Adapter) Remove(ctx context.Context, hash string, deleteFiles bool) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if deleteFiles {
|
||||
_, err := a.rpcLocked(ctx, "aria2.removeDownloadResult", []interface{}{hash})
|
||||
return err
|
||||
}
|
||||
_, err := a.rpcLocked(ctx, "aria2.remove", []interface{}{hash})
|
||||
return err
|
||||
}
|
||||
|
||||
// List 列出所有活动/等待/已停止的任务。
|
||||
func (a *Aria2Adapter) List(ctx context.Context, filter string) ([]TorrentInfo, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
var allResults []TorrentInfo
|
||||
|
||||
// 获取活动任务
|
||||
active, err := a.rpcLocked(ctx, "aria2.tellActive", []interface{}{
|
||||
[]string{"gid", "bittorrent", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections", "errorCode"},
|
||||
})
|
||||
if err == nil && active != nil {
|
||||
items := a.parseAria2Items(active)
|
||||
allResults = append(allResults, items...)
|
||||
}
|
||||
|
||||
// 获取等待中的任务
|
||||
waiting, err := a.rpcLocked(ctx, "aria2.tellWaiting", []interface{}{
|
||||
0, 100,
|
||||
[]string{"gid", "bittorrent", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections", "errorCode"},
|
||||
})
|
||||
if err == nil && waiting != nil {
|
||||
items := a.parseAria2Items(waiting)
|
||||
allResults = append(allResults, items...)
|
||||
}
|
||||
|
||||
// 获取已停止的任务
|
||||
stopped, err := a.rpcLocked(ctx, "aria2.tellStopped", []interface{}{
|
||||
0, 100,
|
||||
[]string{"gid", "bittorrent", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections", "errorCode"},
|
||||
})
|
||||
if err == nil && stopped != nil {
|
||||
items := a.parseAria2Items(stopped)
|
||||
allResults = append(allResults, items...)
|
||||
}
|
||||
|
||||
if filter != "" {
|
||||
filtered := make([]TorrentInfo, 0, len(allResults))
|
||||
for _, item := range allResults {
|
||||
if strings.EqualFold(item.State, filter) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
return allResults, nil
|
||||
}
|
||||
|
||||
// GetInfo 获取单个任务信息。
|
||||
func (a *Aria2Adapter) GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
result, err := a.rpcLocked(ctx, "aria2.tellStatus", []interface{}{
|
||||
hash,
|
||||
[]string{"gid", "bittorrent", "totalLength", "completedLength", "downloadSpeed", "uploadSpeed", "status", "dir", "numSeeders", "connections"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var item map[string]interface{}
|
||||
if err := json.Unmarshal(result, &item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info := a.parseSingleItem(item)
|
||||
if info == nil {
|
||||
return nil, fmt.Errorf("task %s not found", hash)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// parseAria2Items 解析 Aria2 返回的任务列表。
|
||||
func (a *Aria2Adapter) parseAria2Items(raw json.RawMessage) []TorrentInfo {
|
||||
var items []map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &items); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]TorrentInfo, 0, len(items))
|
||||
for _, item := range items {
|
||||
info := a.parseSingleItem(item)
|
||||
if info != nil {
|
||||
result = append(result, *info)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseSingleItem 解析单个 Aria2 任务项。
|
||||
func (a *Aria2Adapter) parseSingleItem(item map[string]interface{}) *TorrentInfo {
|
||||
gid := strVal(item["gid"])
|
||||
totalLength := toInt64(item["totalLength"])
|
||||
completedLength := toInt64(item["completedLength"])
|
||||
dlSpeed := toInt64(item["downloadSpeed"])
|
||||
upSpeed := toInt64(item["uploadSpeed"])
|
||||
status := strVal(item["status"])
|
||||
dir := strVal(item["dir"])
|
||||
numSeeders := int(toInt64(item["numSeeders"]))
|
||||
connections := int(toInt64(item["connections"]))
|
||||
|
||||
var name string
|
||||
var hash string
|
||||
|
||||
// 尝试从 bittorrent info 获取名称和 hash
|
||||
if bt, ok := item["bittorrent"].(map[string]interface{}); ok {
|
||||
if info, ok := bt["info"].(map[string]interface{}); ok {
|
||||
name = strVal(info["name"])
|
||||
}
|
||||
hash = strVal(bt["infoHash"])
|
||||
}
|
||||
|
||||
// 如果没有 bittorrent 信息,使用 GID 作为 hash
|
||||
if hash == "" {
|
||||
hash = gid
|
||||
}
|
||||
if name == "" {
|
||||
// 尝试从 files 获取文件名
|
||||
if files, ok := item["files"].([]interface{}); ok && len(files) > 0 {
|
||||
if f, ok := files[0].(map[string]interface{}); ok {
|
||||
paths, ok := f["path"].([]interface{})
|
||||
if ok && len(paths) > 0 {
|
||||
name = strVal(paths[len(paths)-1])
|
||||
}
|
||||
if name == "" {
|
||||
name = strVal(f["uris"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
name = gid
|
||||
}
|
||||
|
||||
var progress float64
|
||||
if totalLength > 0 {
|
||||
progress = float64(completedLength) / float64(totalLength) * 100
|
||||
}
|
||||
|
||||
// Aria2 状态映射
|
||||
state := aria2StatusStr(status)
|
||||
|
||||
return &TorrentInfo{
|
||||
Hash: hash,
|
||||
Name: name,
|
||||
Size: totalLength,
|
||||
Progress: progress,
|
||||
DLSpeed: dlSpeed,
|
||||
UPSpeed: upSpeed,
|
||||
State: state,
|
||||
SavePath: dir,
|
||||
NumSeeds: numSeeders,
|
||||
NumLeechs: max(connections-numSeeders, 0),
|
||||
AddedOn: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// aria2StatusStr 将 Aria2 状态转为可读字符串。
|
||||
func aria2StatusStr(status string) string {
|
||||
switch status {
|
||||
case "active":
|
||||
return "downloading"
|
||||
case "waiting":
|
||||
return "queued"
|
||||
case "paused":
|
||||
return "paused"
|
||||
case "error":
|
||||
return "error"
|
||||
case "complete":
|
||||
return "seeding"
|
||||
case "removed":
|
||||
return "removed"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
// nextID 生成递增的请求 ID。
|
||||
func (a *Aria2Adapter) nextID() string {
|
||||
a.idSeq++
|
||||
return fmt.Sprintf("msg-%d", a.idSeq)
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
+65
-26
@@ -14,27 +14,29 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// AuthService handles registration, login, and JWT issuance.
|
||||
type AuthService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
tokenSvc *TokenService
|
||||
permissionSvc *PermissionService
|
||||
}
|
||||
|
||||
// NewAuthService is the constructor.
|
||||
func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *AuthService {
|
||||
return &AuthService{cfg: cfg, log: log, repo: repo}
|
||||
func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tokenSvc *TokenService, permissionSvc *PermissionService) *AuthService {
|
||||
return &AuthService{cfg: cfg, log: log, repo: repo, tokenSvc: tokenSvc, permissionSvc: permissionSvc}
|
||||
}
|
||||
|
||||
// Common service-level errors.
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrUsernameTaken = errors.New("username already taken")
|
||||
ErrUserInactive = errors.New("user account is inactive")
|
||||
)
|
||||
|
||||
// SeedAdmin makes sure at least one admin user exists. It mirrors the
|
||||
@@ -60,11 +62,14 @@ func (s *AuthService) SeedAdmin(ctx context.Context) error {
|
||||
Username: "admin",
|
||||
PasswordHash: hash,
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
ForcePasswordReset: pwd == "admin123",
|
||||
}
|
||||
if err := s.repo.User.Create(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
// 确保管理员有权限记录
|
||||
_, _ = s.permissionSvc.EnsureForUser(ctx, user.ID)
|
||||
s.log.Warn("default admin created — change the password after first login",
|
||||
zap.String("username", "admin"),
|
||||
zap.String("password_source", "ADMIN_INITIAL_PASSWORD or admin123"),
|
||||
@@ -74,49 +79,72 @@ func (s *AuthService) SeedAdmin(ctx context.Context) error {
|
||||
|
||||
// Register creates a new user. The first registered user is auto-promoted to
|
||||
// admin to support fresh installs that did not run SeedAdmin.
|
||||
func (s *AuthService) Register(ctx context.Context, username, password string) (*model.User, error) {
|
||||
func (s *AuthService) Register(ctx context.Context, username, password string) (*model.User, *TokenPair, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" || password == "" {
|
||||
return nil, fmt.Errorf("username and password required")
|
||||
return nil, nil, fmt.Errorf("username and password required")
|
||||
}
|
||||
if existing, err := s.repo.User.FindByUsername(ctx, username); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
} else if existing != nil {
|
||||
return nil, ErrUsernameTaken
|
||||
return nil, nil, ErrUsernameTaken
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
role := "user"
|
||||
if n, err := s.repo.User.CountAdmins(ctx); err == nil && n == 0 {
|
||||
role = "admin"
|
||||
}
|
||||
u := &model.User{Username: username, PasswordHash: hash, Role: role}
|
||||
if err := s.repo.User.Create(ctx, u); err != nil {
|
||||
return nil, err
|
||||
u := &model.User{
|
||||
Username: username,
|
||||
PasswordHash: hash,
|
||||
Role: role,
|
||||
Tier: "free",
|
||||
}
|
||||
return u, nil
|
||||
if err := s.repo.User.Create(ctx, u); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 自动为新用户创建默认权限
|
||||
_, _ = s.permissionSvc.EnsureForUser(ctx, u.ID)
|
||||
// 签发令牌对
|
||||
tokens, err := s.tokenSvc.IssuePair(ctx, u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
return u, nil, nil // 用户已创建,令牌签发失败不影响注册成功
|
||||
}
|
||||
return u, tokens, nil
|
||||
}
|
||||
|
||||
// Login validates credentials and returns the user + a fresh JWT.
|
||||
func (s *AuthService) Login(ctx context.Context, username, password string) (*model.User, string, error) {
|
||||
// LoginResponse 登录响应结构。
|
||||
type LoginResponse struct {
|
||||
User *model.User `json:"user"`
|
||||
Tokens *TokenPair `json:"tokens"`
|
||||
}
|
||||
|
||||
// Login validates credentials and returns the user + a fresh JWT token pair.
|
||||
func (s *AuthService) Login(ctx context.Context, username, password string) (*LoginResponse, error) {
|
||||
u, err := s.repo.User.FindByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
if u == nil {
|
||||
return nil, "", ErrInvalidCredentials
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
// 检查用户是否激活
|
||||
if !u.IsActive {
|
||||
return nil, ErrUserInactive
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, "", ErrInvalidCredentials
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
token, err := s.IssueToken(u)
|
||||
// 签发令牌对
|
||||
tokens, err := s.tokenSvc.IssuePair(ctx, u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
_ = s.repo.User.TouchLogin(ctx, u.ID)
|
||||
return u, token, nil
|
||||
return &LoginResponse{User: u, Tokens: tokens}, nil
|
||||
}
|
||||
|
||||
// ChangePassword updates the user password if the old one matches.
|
||||
@@ -138,14 +166,15 @@ func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd
|
||||
return s.repo.User.UpdatePassword(ctx, userID, hash)
|
||||
}
|
||||
|
||||
// IssueToken signs a JWT for the given user (24h validity).
|
||||
// IssueToken signs a JWT for the given user (60min validity, includes tier).
|
||||
func (s *AuthService) IssueToken(u *model.User) (string, error) {
|
||||
claims := middleware.Claims{
|
||||
claims := Claims{
|
||||
UserID: u.ID,
|
||||
Role: u.Role,
|
||||
Tier: u.Tier,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(60 * time.Minute)),
|
||||
Issuer: "mediastationgo",
|
||||
Subject: u.ID,
|
||||
},
|
||||
@@ -154,6 +183,16 @@ func (s *AuthService) IssueToken(u *model.User) (string, error) {
|
||||
return t.SignedString([]byte(s.cfg.Secrets.JWTSecret))
|
||||
}
|
||||
|
||||
// RefreshTokens 使用刷新令牌获取新的令牌对。
|
||||
func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||
return s.tokenSvc.Refresh(ctx, refreshToken)
|
||||
}
|
||||
|
||||
// Logout 撤销用户的所有刷新令牌。
|
||||
func (s *AuthService) Logout(ctx context.Context, userID string) error {
|
||||
return s.tokenSvc.RevokeAll(ctx, userID)
|
||||
}
|
||||
|
||||
func hashPassword(p string) (string, error) {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
|
||||
@@ -99,6 +99,11 @@ func (c *CryptoService) Decrypt(value string) string {
|
||||
return string(plain)
|
||||
}
|
||||
|
||||
// IsEncrypted returns true if value carries the encrypted prefix.
|
||||
func (c *CryptoService) IsEncrypted(value string) bool {
|
||||
return strings.HasPrefix(value, encPrefix)
|
||||
}
|
||||
|
||||
// MaskAPIKey returns "abcd****wxyz" so the key can be displayed in the
|
||||
// admin UI without leaking it. Inputs shorter than 8 chars become "****".
|
||||
func MaskAPIKey(plain string) string {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package service 定义下载适配器接口和通用数据结构。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadAdapter 定义下载客户端的统一接口。
|
||||
// 所有下载客户端(qBittorrent / Transmission / Aria2)必须实现此接口。
|
||||
type DownloadAdapter interface {
|
||||
// Initialize 使用配置初始化客户端连接。
|
||||
Initialize(ctx context.Context, cfg DownloadClientConfig) error
|
||||
// Ping 测试客户端连接是否可用。
|
||||
Ping(ctx context.Context) error
|
||||
// AddTorrent 通过 URL(磁力链接或种子 URL)添加下载任务。
|
||||
AddTorrent(ctx context.Context, url, savePath string) (string, error)
|
||||
// AddMagnet 通过磁力链接添加下载任务。
|
||||
AddMagnet(ctx context.Context, magnet, savePath string) (string, error)
|
||||
// Pause 暂停指定下载任务。
|
||||
Pause(ctx context.Context, hash string) error
|
||||
// Resume 恢复指定下载任务。
|
||||
Resume(ctx context.Context, hash string) error
|
||||
// Remove 移除指定下载任务,deleteFiles 控制是否同时删除文件。
|
||||
Remove(ctx context.Context, hash string, deleteFiles bool) error
|
||||
// List 列出所有或过滤后的种子任务。filter 可为空字符串表示全部。
|
||||
List(ctx context.Context, filter string) ([]TorrentInfo, error)
|
||||
// GetInfo 获取指定种子的详细信息。
|
||||
GetInfo(ctx context.Context, hash string) (*TorrentInfo, error)
|
||||
}
|
||||
|
||||
// TorrentInfo 是各种下载客户端的种子信息的统一表示。
|
||||
type TorrentInfo struct {
|
||||
Hash string `json:"hash"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Progress float64 `json:"progress"`
|
||||
DLSpeed int64 `json:"dl_speed"`
|
||||
UPSpeed int64 `json:"up_speed"`
|
||||
State string `json:"state"`
|
||||
SavePath string `json:"save_path"`
|
||||
NumSeeds int `json:"num_seeds"`
|
||||
NumLeechs int `json:"num_leechs"`
|
||||
AddedOn time.Time `json:"added_on"`
|
||||
Category string `json:"category"`
|
||||
Tags string `json:"tags"`
|
||||
}
|
||||
|
||||
// DownloadClientConfig 是下载客户端的连接配置。
|
||||
type DownloadClientConfig struct {
|
||||
Host string `json:"host"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// AdapterFactory 根据客户端类型创建适配器实例。
|
||||
func AdapterFactory(clientType string) DownloadAdapter {
|
||||
switch clientType {
|
||||
case "qbittorrent":
|
||||
return NewQBitAdapter()
|
||||
case "transmission":
|
||||
return NewTransmissionAdapter()
|
||||
case "aria2":
|
||||
return NewAria2Adapter()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Package service — 下载管理器,管理多个下载客户端适配器。
|
||||
//
|
||||
// DownloadManager 提供多客户端分发能力,支持运行时热插拔。
|
||||
// 调用方通过 GetDefault() 或 GetClient(id) 获取适配器来执行下载操作。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// DownloadManager 管理多个下载客户端适配器实例。
|
||||
type DownloadManager struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
crypto *CryptoService
|
||||
|
||||
mu sync.RWMutex
|
||||
clients map[string]DownloadAdapter // clientID -> adapter
|
||||
configs map[string]DownloadClientConfig
|
||||
}
|
||||
|
||||
// NewDownloadManager 创建新的下载管理器。
|
||||
func NewDownloadManager(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *DownloadManager {
|
||||
return &DownloadManager{
|
||||
log: log,
|
||||
repo: repo,
|
||||
crypto: crypto,
|
||||
clients: make(map[string]DownloadAdapter),
|
||||
configs: make(map[string]DownloadClientConfig),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAll 从数据库加载所有已启用的客户端并初始化适配器。
|
||||
func (m *DownloadManager) LoadAll(ctx context.Context) error {
|
||||
dbClients, err := m.repo.DownloadClient.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// 清空现有
|
||||
m.clients = make(map[string]DownloadAdapter, len(dbClients))
|
||||
m.configs = make(map[string]DownloadClientConfig, len(dbClients))
|
||||
|
||||
for _, dc := range dbClients {
|
||||
cfg, err := m.buildConfig(&dc)
|
||||
if err != nil {
|
||||
m.log.Warn("failed to build config for download client",
|
||||
zap.String("id", dc.ID),
|
||||
zap.String("name", dc.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
adapter := AdapterFactory(dc.Type)
|
||||
if adapter == nil {
|
||||
m.log.Warn("unknown download client type",
|
||||
zap.String("type", dc.Type),
|
||||
zap.String("id", dc.ID),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if initErr := adapter.Initialize(ctx, cfg); initErr != nil {
|
||||
m.log.Warn("failed to initialize download client",
|
||||
zap.String("id", dc.ID),
|
||||
zap.String("name", dc.Name),
|
||||
zap.Error(initErr),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
m.clients[dc.ID] = adapter
|
||||
m.configs[dc.ID] = cfg
|
||||
m.log.Info("download client initialized",
|
||||
zap.String("id", dc.ID),
|
||||
zap.String("name", dc.Name),
|
||||
zap.String("type", dc.Type),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDefault 返回默认下载客户端适配器。
|
||||
// 如果没有设置默认客户端,返回第一个可用的客户端。
|
||||
func (m *DownloadManager) GetDefault() (string, DownloadAdapter, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// 首先找默认的
|
||||
defaultClient, err := m.repo.DownloadClient.FindDefault(context.Background())
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if defaultClient != nil {
|
||||
if adapter, ok := m.clients[defaultClient.ID]; ok {
|
||||
return defaultClient.ID, adapter, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 返回第一个可用的
|
||||
for id, adapter := range m.clients {
|
||||
return id, adapter, nil
|
||||
}
|
||||
|
||||
return "", nil, errors.New("no download client available")
|
||||
}
|
||||
|
||||
// GetClient 返回指定 ID 的下载客户端适配器。
|
||||
func (m *DownloadManager) GetClient(id string) (DownloadAdapter, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
adapter, ok := m.clients[id]
|
||||
if !ok {
|
||||
return nil, errors.New("download client not found or not initialized")
|
||||
}
|
||||
return adapter, nil
|
||||
}
|
||||
|
||||
// AddClient 动态添加并初始化一个下载客户端。
|
||||
func (m *DownloadManager) AddClient(ctx context.Context, dc *model.DownloadClient) error {
|
||||
cfg, err := m.buildConfig(dc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
adapter := AdapterFactory(dc.Type)
|
||||
if adapter == nil {
|
||||
return errors.New("unknown download client type: " + dc.Type)
|
||||
}
|
||||
|
||||
if err := adapter.Initialize(ctx, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.clients[dc.ID] = adapter
|
||||
m.configs[dc.ID] = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveClient 移除一个下载客户端(停止适配器,不删除数据库记录)。
|
||||
func (m *DownloadManager) RemoveClient(id string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.clients, id)
|
||||
delete(m.configs, id)
|
||||
}
|
||||
|
||||
// UpdateClient 更新已有客户端的配置并重新初始化。
|
||||
func (m *DownloadManager) UpdateClient(ctx context.Context, dc *model.DownloadClient) error {
|
||||
m.RemoveClient(dc.ID)
|
||||
return m.AddClient(ctx, dc)
|
||||
}
|
||||
|
||||
// TestConnection 测试客户端连接。
|
||||
func (m *DownloadManager) TestConnection(ctx context.Context, dc *model.DownloadClient) error {
|
||||
cfg, err := m.buildConfig(dc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
adapter := AdapterFactory(dc.Type)
|
||||
if adapter == nil {
|
||||
return errors.New("unknown download client type: " + dc.Type)
|
||||
}
|
||||
|
||||
return adapter.Initialize(ctx, cfg)
|
||||
}
|
||||
|
||||
// ListAll 获取所有已加载客户端的种子列表。
|
||||
func (m *DownloadManager) ListAll(ctx context.Context, filter string) (map[string][]TorrentInfo, error) {
|
||||
m.mu.RLock()
|
||||
ids := make([]string, 0, len(m.clients))
|
||||
for id := range m.clients {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
adapters := make([]DownloadAdapter, 0, len(m.clients))
|
||||
for _, id := range ids {
|
||||
adapters = append(adapters, m.clients[id])
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
result := make(map[string][]TorrentInfo)
|
||||
for i, id := range ids {
|
||||
list, err := adapters[i].List(ctx, filter)
|
||||
if err != nil {
|
||||
m.log.Warn("failed to list torrents from client",
|
||||
zap.String("id", id),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
result[id] = list
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAdapterTypes 返回支持的下载客户端类型列表。
|
||||
func (m *DownloadManager) GetAdapterTypes() []AdapterTypeInfo {
|
||||
return []AdapterTypeInfo{
|
||||
{Type: "qbittorrent", Name: "qBittorrent", Description: "qBittorrent WebUI API (v2)"},
|
||||
{Type: "transmission", Name: "Transmission", Description: "Transmission RPC API"},
|
||||
{Type: "aria2", Name: "Aria2", Description: "Aria2 JSON-RPC API"},
|
||||
}
|
||||
}
|
||||
|
||||
// AdapterTypeInfo 描述下载客户端类型信息。
|
||||
type AdapterTypeInfo struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// buildConfig 从数据库模型构建适配器配置。
|
||||
func (m *DownloadManager) buildConfig(dc *model.DownloadClient) (DownloadClientConfig, error) {
|
||||
password := dc.Password
|
||||
if m.crypto != nil && password != "" {
|
||||
password = m.crypto.Decrypt(password)
|
||||
}
|
||||
|
||||
cfg := DownloadClientConfig{
|
||||
Host: dc.Host,
|
||||
Username: dc.Username,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
// 解析 Extra JSON 配置
|
||||
if dc.Extra != "" {
|
||||
extraStr := dc.Extra
|
||||
if m.crypto != nil {
|
||||
extraStr = m.crypto.Decrypt(extraStr)
|
||||
}
|
||||
var extra map[string]string
|
||||
if err := json.Unmarshal([]byte(extraStr), &extra); err == nil {
|
||||
cfg.Extra = extra
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -141,3 +141,79 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, raw strin
|
||||
http.ServeContent(w, &http.Request{}, key, stat.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch 拉取远程图片并返回字节和 Content-Type(带缓存)。
|
||||
func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) {
|
||||
if raw == "" {
|
||||
return nil, "", errors.New("missing url")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return nil, "", errors.New("invalid url")
|
||||
}
|
||||
if _, ok := p.allowHost[strings.ToLower(u.Host)]; !ok {
|
||||
return nil, "", errors.New("host not allowed")
|
||||
}
|
||||
|
||||
// Cache lookup
|
||||
sum := sha1.Sum([]byte(raw))
|
||||
key := hex.EncodeToString(sum[:])
|
||||
cachePath := filepath.Join(p.cacheDir, key)
|
||||
|
||||
if data, err := os.ReadFile(cachePath); err == nil {
|
||||
// Content-Type from file extension or upstream headers — use a simple detect
|
||||
ctype := detectContentType(data)
|
||||
return data, ctype, nil
|
||||
}
|
||||
|
||||
// Fetch upstream
|
||||
if err := os.MkdirAll(p.cacheDir, 0o755); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "MediaStationGo/0.1")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, "", errors.New("upstream returned " + resp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Write to cache
|
||||
tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp")
|
||||
if err == nil {
|
||||
if _, err := tmp.Write(data); err == nil {
|
||||
tmp.Close()
|
||||
os.Rename(tmp.Name(), cachePath)
|
||||
} else {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
}
|
||||
}
|
||||
|
||||
ctype := resp.Header.Get("Content-Type")
|
||||
if ctype == "" {
|
||||
ctype = detectContentType(data)
|
||||
}
|
||||
return data, ctype, nil
|
||||
}
|
||||
|
||||
// detectContentType 通过前 512 字节检测 MIME 类型。
|
||||
func detectContentType(data []byte) string {
|
||||
if len(data) > 512 {
|
||||
return http.DetectContentType(data[:512])
|
||||
}
|
||||
return http.DetectContentType(data)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Package service — Bark 通知 Provider。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BarkProvider 通过 Bark 推送通知到 iOS 设备。
|
||||
// Bark API 文档: https://github.com/Finb/bark-server
|
||||
type BarkProvider struct{}
|
||||
|
||||
// Send 发送 Bark 推送通知。
|
||||
func (p *BarkProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error {
|
||||
serverURL := cfg["server_url"]
|
||||
deviceKey := cfg["device_key"]
|
||||
if serverURL == "" {
|
||||
serverURL = "https://api.day.app"
|
||||
}
|
||||
serverURL = strings.TrimRight(serverURL, "/")
|
||||
if deviceKey == "" {
|
||||
return fmt.Errorf("bark: device_key is required")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"title": event.Title,
|
||||
"body": event.Message,
|
||||
"group": "MediaStationGo",
|
||||
}
|
||||
|
||||
if len(event.Data) > 0 {
|
||||
var extra string
|
||||
for k, v := range event.Data {
|
||||
extra += fmt.Sprintf("%s: %v\n", k, v)
|
||||
}
|
||||
payload["body"] = event.Message + "\n\n" + extra
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/%s", serverURL, deviceKey)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("bark api error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateConfig 验证 Bark 配置。
|
||||
func (p *BarkProvider) ValidateConfig(cfg map[string]string) error {
|
||||
if cfg["device_key"] == "" {
|
||||
return fmt.Errorf("bark: device_key is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package service — Email(SMTP) 通知 Provider。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EmailProvider 通过 SMTP 发送邮件通知。
|
||||
type EmailProvider struct{}
|
||||
|
||||
// Send 通过 SMTP 发送邮件。
|
||||
func (p *EmailProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error {
|
||||
smtpHost := cfg["smtp_host"]
|
||||
smtpPortStr := cfg["smtp_port"]
|
||||
username := cfg["username"]
|
||||
password := cfg["password"]
|
||||
from := cfg["from"]
|
||||
to := cfg["to"]
|
||||
tlsStr := cfg["tls"]
|
||||
|
||||
if smtpHost == "" || smtpPortStr == "" || username == "" || from == "" || to == "" {
|
||||
return fmt.Errorf("email: smtp_host, smtp_port, username, from, and to are required")
|
||||
}
|
||||
|
||||
smtpPort, err := strconv.Atoi(smtpPortStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("email: invalid smtp_port: %s", smtpPortStr)
|
||||
}
|
||||
|
||||
useTLS := true
|
||||
if tlsStr == "false" || tlsStr == "0" || tlsStr == "no" {
|
||||
useTLS = false
|
||||
}
|
||||
|
||||
// 构建邮件内容
|
||||
subject := fmt.Sprintf("[MediaStationGo] %s", event.Title)
|
||||
body := event.Message
|
||||
if len(event.Data) > 0 {
|
||||
body += "\n\n---\n详细信息:\n"
|
||||
for k, v := range event.Data {
|
||||
body += fmt.Sprintf(" %s: %v\n", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
recipients := strings.Split(to, ",")
|
||||
for i, r := range recipients {
|
||||
recipients[i] = strings.TrimSpace(r)
|
||||
}
|
||||
|
||||
// 构建邮件
|
||||
fromAddr := mail.Address{Name: "MediaStationGo", Address: from}
|
||||
toAddrs := make([]mail.Address, 0, len(recipients))
|
||||
for _, r := range recipients {
|
||||
toAddrs = append(toAddrs, mail.Address{Address: r})
|
||||
}
|
||||
|
||||
msg := "From: " + fromAddr.String() + "\r\n"
|
||||
msg += "To: "
|
||||
for i, addr := range toAddrs {
|
||||
if i > 0 {
|
||||
msg += ", "
|
||||
}
|
||||
msg += addr.String()
|
||||
}
|
||||
msg += "\r\n"
|
||||
msg += "Subject: " + subject + "\r\n"
|
||||
msg += "MIME-Version: 1.0\r\n"
|
||||
msg += "Content-Type: text/plain; charset=\"utf-8\"\r\n"
|
||||
msg += "Content-Transfer-Encoding: base64\r\n"
|
||||
msg += "\r\n"
|
||||
msg += body
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", smtpHost, smtpPort)
|
||||
auth := smtp.PlainAuth("", username, password, smtpHost)
|
||||
|
||||
if useTLS {
|
||||
// 使用 TLS 连接
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: smtpHost,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("email tls dial: %w", err)
|
||||
}
|
||||
client, err := smtp.NewClient(conn, smtpHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("email smtp client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("email auth: %w", err)
|
||||
}
|
||||
if err = client.Mail(from); err != nil {
|
||||
return fmt.Errorf("email mail from: %w", err)
|
||||
}
|
||||
for _, r := range recipients {
|
||||
if err = client.Rcpt(r); err != nil {
|
||||
return fmt.Errorf("email rcpt to: %w", err)
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("email data: %w", err)
|
||||
}
|
||||
if _, err = w.Write([]byte(msg)); err != nil {
|
||||
return fmt.Errorf("email write: %w", err)
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("email close: %w", err)
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// 不使用 TLS(STARTTLS 或明文)
|
||||
return smtp.SendMail(addr, auth, from, recipients, []byte(msg))
|
||||
}
|
||||
|
||||
// ValidateConfig 验证 Email 配置。
|
||||
func (p *EmailProvider) ValidateConfig(cfg map[string]string) error {
|
||||
if cfg["smtp_host"] == "" {
|
||||
return fmt.Errorf("email: smtp_host is required")
|
||||
}
|
||||
if cfg["smtp_port"] == "" {
|
||||
return fmt.Errorf("email: smtp_port is required")
|
||||
}
|
||||
if cfg["username"] == "" {
|
||||
return fmt.Errorf("email: username is required")
|
||||
}
|
||||
if cfg["from"] == "" {
|
||||
return fmt.Errorf("email: from is required")
|
||||
}
|
||||
if cfg["to"] == "" {
|
||||
return fmt.Errorf("email: to is required")
|
||||
}
|
||||
if _, err := strconv.Atoi(cfg["smtp_port"]); err != nil {
|
||||
return fmt.Errorf("email: invalid smtp_port")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Package service — 通知服务事件分发引擎。
|
||||
//
|
||||
// NotifyService 管理所有通知渠道,根据事件类型将通知分发给
|
||||
// 订阅了该事件的渠道。支持 4 种内置事件类型和 5 种通知渠道。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// 通知事件类型常量。
|
||||
const (
|
||||
EventSubscriptionHit = "subscription_hit"
|
||||
EventDownloadComplete = "download_complete"
|
||||
EventScrapeFailed = "scrape_failed"
|
||||
EventSystemAlert = "system_alert"
|
||||
)
|
||||
|
||||
// NotifyEvent 是通知事件的数据结构。
|
||||
type NotifyEvent struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// NotifyProvider 定义通知渠道的发送接口。
|
||||
type NotifyProvider interface {
|
||||
Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error
|
||||
ValidateConfig(cfg map[string]string) error
|
||||
}
|
||||
|
||||
// NotifyService 是事件驱动的通知分发引擎。
|
||||
type NotifyService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
crypto *CryptoService
|
||||
|
||||
mu sync.RWMutex
|
||||
providers map[string]NotifyProvider // type -> provider
|
||||
}
|
||||
|
||||
// NewNotifyService 创建通知服务。
|
||||
func NewNotifyService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *NotifyService {
|
||||
ns := &NotifyService{
|
||||
log: log,
|
||||
repo: repo,
|
||||
crypto: crypto,
|
||||
providers: make(map[string]NotifyProvider),
|
||||
}
|
||||
// 注册内置 Provider
|
||||
ns.registerProviders()
|
||||
return ns
|
||||
}
|
||||
|
||||
// registerProviders 注册所有内置通知 Provider。
|
||||
func (s *NotifyService) registerProviders() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.providers["telegram"] = &TelegramProvider{}
|
||||
s.providers["wechat"] = &WechatProvider{}
|
||||
s.providers["bark"] = &BarkProvider{}
|
||||
s.providers["webhook"] = &WebhookProvider{}
|
||||
s.providers["email"] = &EmailProvider{}
|
||||
}
|
||||
|
||||
// Dispatch 将事件分发给所有订阅了该事件类型的已启用渠道。
|
||||
func (s *NotifyService) Dispatch(ctx context.Context, event NotifyEvent) {
|
||||
channels, err := s.repo.NotifyChannel.ListByEvent(ctx, event.Type)
|
||||
if err != nil {
|
||||
s.log.Error("failed to list channels for event",
|
||||
zap.String("event", event.Type),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
go func(channel model.NotifyChannel) {
|
||||
if sendErr := s.sendToChannel(ctx, channel, event); sendErr != nil {
|
||||
s.log.Error("failed to send notification",
|
||||
zap.String("channel", channel.Name),
|
||||
zap.String("type", channel.Type),
|
||||
zap.String("event", event.Type),
|
||||
zap.Error(sendErr),
|
||||
)
|
||||
}
|
||||
}(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// SendTest 向指定渠道发送测试通知。
|
||||
func (s *NotifyService) SendTest(ctx context.Context, channelID string) error {
|
||||
ch, err := s.repo.NotifyChannel.FindByID(ctx, channelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ch == nil {
|
||||
return ErrNotifyChannelNotFound
|
||||
}
|
||||
|
||||
testEvent := NotifyEvent{
|
||||
Type: "test",
|
||||
Title: "MediaStationGo 测试通知",
|
||||
Message: "这是一条测试通知,如果您看到此消息,说明通知渠道配置正确。",
|
||||
}
|
||||
|
||||
return s.sendToChannel(ctx, *ch, testEvent)
|
||||
}
|
||||
|
||||
// ValidateChannelConfig 验证渠道配置是否合法。
|
||||
func (s *NotifyService) ValidateChannelConfig(channelType string, config map[string]string) error {
|
||||
s.mu.RLock()
|
||||
provider, ok := s.providers[channelType]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return ErrUnknownNotifyType
|
||||
}
|
||||
return provider.ValidateConfig(config)
|
||||
}
|
||||
|
||||
// GetProviderTypes 返回支持的通知渠道类型列表。
|
||||
func (s *NotifyService) GetProviderTypes() []NotifyProviderInfo {
|
||||
return []NotifyProviderInfo{
|
||||
{Type: "telegram", Name: "Telegram", Description: "通过 Telegram Bot 发送消息"},
|
||||
{Type: "wechat", Name: "Server酱", Description: "通过 Server酱 推送到微信"},
|
||||
{Type: "bark", Name: "Bark", Description: "通过 Bark 推送到 iOS"},
|
||||
{Type: "webhook", Name: "Webhook", Description: "通过自定义 HTTP Webhook 发送"},
|
||||
{Type: "email", Name: "Email", Description: "通过 SMTP 发送邮件"},
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyProviderInfo 描述通知渠道类型信息。
|
||||
type NotifyProviderInfo struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// sendToChannel 解密渠道配置并通过对应的 Provider 发送通知。
|
||||
func (s *NotifyService) sendToChannel(ctx context.Context, channel model.NotifyChannel, event NotifyEvent) error {
|
||||
// 解密配置
|
||||
configStr := channel.Config
|
||||
if s.crypto != nil && configStr != "" {
|
||||
configStr = s.crypto.Decrypt(configStr)
|
||||
}
|
||||
|
||||
var cfg map[string]string
|
||||
if err := json.Unmarshal([]byte(configStr), &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
provider, ok := s.providers[channel.Type]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return ErrUnknownNotifyType
|
||||
}
|
||||
|
||||
return provider.Send(ctx, cfg, event)
|
||||
}
|
||||
|
||||
// 通知服务错误定义。
|
||||
var (
|
||||
ErrNotifyChannelNotFound = &NotifyError{Code: "CHANNEL_NOT_FOUND", Message: "notification channel not found"}
|
||||
ErrUnknownNotifyType = &NotifyError{Code: "UNKNOWN_TYPE", Message: "unknown notification type"}
|
||||
)
|
||||
|
||||
// NotifyError 是通知服务专用错误类型。
|
||||
type NotifyError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error 实现 error 接口。
|
||||
func (e *NotifyError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package service — Telegram 通知 Provider。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TelegramProvider 通过 Telegram Bot API 发送通知。
|
||||
type TelegramProvider struct{}
|
||||
|
||||
// Send 发送 Telegram 消息。
|
||||
func (p *TelegramProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error {
|
||||
botToken := cfg["bot_token"]
|
||||
chatID := cfg["chat_id"]
|
||||
parseMode := cfg["parse_mode"]
|
||||
if parseMode == "" {
|
||||
parseMode = "HTML"
|
||||
}
|
||||
|
||||
if botToken == "" || chatID == "" {
|
||||
return fmt.Errorf("telegram: bot_token and chat_id are required")
|
||||
}
|
||||
|
||||
text := formatTelegramMessage(event, parseMode)
|
||||
|
||||
payload := map[string]string{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
"parse_mode": parseMode,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("telegram api error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateConfig 验证 Telegram 配置。
|
||||
func (p *TelegramProvider) ValidateConfig(cfg map[string]string) error {
|
||||
if cfg["bot_token"] == "" {
|
||||
return fmt.Errorf("telegram: bot_token is required")
|
||||
}
|
||||
if cfg["chat_id"] == "" {
|
||||
return fmt.Errorf("telegram: chat_id is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatTelegramMessage 格式化消息内容。
|
||||
func formatTelegramMessage(event NotifyEvent, parseMode string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("<b>%s</b>\n\n", escapeHTML(event.Title)))
|
||||
sb.WriteString(escapeHTML(event.Message))
|
||||
|
||||
if len(event.Data) > 0 {
|
||||
sb.WriteString("\n\n")
|
||||
for k, v := range event.Data {
|
||||
sb.WriteString(fmt.Sprintf("• <b>%s</b>: %v\n", escapeHTML(k), v))
|
||||
}
|
||||
}
|
||||
|
||||
if parseMode != "HTML" {
|
||||
// Markdown 模式
|
||||
result := sb.String()
|
||||
result = strings.ReplaceAll(result, "<b>", "**")
|
||||
result = strings.ReplaceAll(result, "</b>", "**")
|
||||
result = strings.ReplaceAll(result, "<", "<")
|
||||
result = strings.ReplaceAll(result, ">", ">")
|
||||
result = strings.ReplaceAll(result, "&", "&")
|
||||
return result
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package service — Webhook 通知 Provider。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebhookProvider 通过自定义 HTTP Webhook 发送通知。
|
||||
// 支持自定义 HTTP 方法和请求头。
|
||||
type WebhookProvider struct{}
|
||||
|
||||
// Send 发送 Webhook 通知。
|
||||
func (p *WebhookProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error {
|
||||
webhookURL := cfg["url"]
|
||||
if webhookURL == "" {
|
||||
return fmt.Errorf("webhook: url is required")
|
||||
}
|
||||
|
||||
method := cfg["method"]
|
||||
if method == "" {
|
||||
method = "POST"
|
||||
}
|
||||
method = strings.ToUpper(method)
|
||||
|
||||
// 构建请求体
|
||||
bodyTemplate := cfg["body_template"]
|
||||
var bodyStr string
|
||||
if bodyTemplate != "" {
|
||||
bodyStr = renderTemplate(bodyTemplate, event)
|
||||
} else {
|
||||
// 默认 JSON 格式
|
||||
bodyStr = fmt.Sprintf(`{"type":"%s","title":"%s","message":"%s","data":{}}`,
|
||||
event.Type, event.Title, event.Message)
|
||||
if len(event.Data) > 0 {
|
||||
var dataParts []string
|
||||
for k, v := range event.Data {
|
||||
dataParts = append(dataParts, fmt.Sprintf(`"%s":%v`, k, v))
|
||||
}
|
||||
bodyStr = fmt.Sprintf(`{"type":"%s","title":"%s","message":"%s","data":{%s}}`,
|
||||
event.Type, event.Title, event.Message, strings.Join(dataParts, ","))
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, webhookURL, strings.NewReader(bodyStr))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 自定义请求头
|
||||
headersJSON := cfg["headers_json"]
|
||||
if headersJSON != "" {
|
||||
headers := parseHeadersJSON(headersJSON)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webhook error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateConfig 验证 Webhook 配置。
|
||||
func (p *WebhookProvider) ValidateConfig(cfg map[string]string) error {
|
||||
if cfg["url"] == "" {
|
||||
return fmt.Errorf("webhook: url is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTemplate 简单模板渲染,支持 {{title}}, {{message}}, {{type}} 占位符。
|
||||
func renderTemplate(template string, event NotifyEvent) string {
|
||||
result := template
|
||||
result = strings.ReplaceAll(result, "{{title}}", event.Title)
|
||||
result = strings.ReplaceAll(result, "{{message}}", event.Message)
|
||||
result = strings.ReplaceAll(result, "{{type}}", event.Type)
|
||||
return result
|
||||
}
|
||||
|
||||
// parseHeadersJSON 简单解析 headers JSON(格式: {"key":"value",...})。
|
||||
func parseHeadersJSON(jsonStr string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
jsonStr = strings.TrimSpace(jsonStr)
|
||||
if jsonStr == "" || (jsonStr[0] != '{' && jsonStr[len(jsonStr)-1] != '}') {
|
||||
return result
|
||||
}
|
||||
|
||||
// 简单 key:value 解析
|
||||
inner := jsonStr[1 : len(jsonStr)-1]
|
||||
parts := strings.Split(inner, ",")
|
||||
for _, part := range parts {
|
||||
kv := strings.SplitN(part, ":", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.Trim(strings.TrimSpace(kv[0]), `"`)
|
||||
value := strings.Trim(strings.TrimSpace(kv[1]), `"`)
|
||||
if key != "" {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Package service — Server酱(WeChat) 通知 Provider。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WechatProvider 通过 Server酱 API 推送消息到微信。
|
||||
// Server酱 API 文档: https://sct.ftqq.com/
|
||||
type WechatProvider struct{}
|
||||
|
||||
// Send 发送 Server酱 推送消息。
|
||||
func (p *WechatProvider) Send(ctx context.Context, cfg map[string]string, event NotifyEvent) error {
|
||||
sendkey := cfg["sendkey"]
|
||||
if sendkey == "" {
|
||||
return fmt.Errorf("wechat: sendkey is required")
|
||||
}
|
||||
|
||||
payload := map[string]string{
|
||||
"title": event.Title,
|
||||
"desp": event.Message,
|
||||
}
|
||||
if len(event.Data) > 0 {
|
||||
payload["desp"] += "\n\n---\n\n"
|
||||
for k, v := range event.Data {
|
||||
payload["desp"] += fmt.Sprintf("- **%s**: %v\n", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://sctapi.ftqq.com/%s.send", sendkey)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("wechat server酱 api error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// 检查 Server酱 响应
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err == nil {
|
||||
if code, ok := result["code"].(float64); ok && code != 0 {
|
||||
msg, _ := result["message"].(string)
|
||||
return fmt.Errorf("wechat server酱 error: %s", msg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateConfig 验证 Server酱 配置。
|
||||
func (p *WechatProvider) ValidateConfig(cfg map[string]string) error {
|
||||
if cfg["sendkey"] == "" {
|
||||
return fmt.Errorf("wechat: sendkey is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Package service — 权限管理服务。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// PermissionService 负责用户细粒度权限管理。
|
||||
type PermissionService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
}
|
||||
|
||||
// NewPermissionService 创建权限服务实例。
|
||||
func NewPermissionService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *PermissionService {
|
||||
return &PermissionService{cfg: cfg, log: log, repo: repo}
|
||||
}
|
||||
|
||||
// 权限服务错误定义。
|
||||
var (
|
||||
ErrPermissionDenied = errors.New("permission denied")
|
||||
ErrPermissionNotFound = errors.New("permission not found")
|
||||
)
|
||||
|
||||
// GetByUserID 获取用户的权限记录,不存在则返回默认权限。
|
||||
func (s *PermissionService) GetByUserID(ctx context.Context, userID string) (*model.UserPermission, error) {
|
||||
perm, err := s.repo.Permission.FindByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if perm == nil {
|
||||
// 返回默认权限但不持久化
|
||||
return model.NewDefaultPermission(userID), nil
|
||||
}
|
||||
return perm, nil
|
||||
}
|
||||
|
||||
// EnsureForUser 确保用户拥有权限记录,不存在则创建默认权限。
|
||||
func (s *PermissionService) EnsureForUser(ctx context.Context, userID string) (*model.UserPermission, error) {
|
||||
perm, err := s.repo.Permission.FindByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if perm != nil {
|
||||
return perm, nil
|
||||
}
|
||||
// 创建默认权限
|
||||
defaultPerm := model.NewDefaultPermission(userID)
|
||||
if err := s.repo.Permission.Upsert(ctx, defaultPerm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return defaultPerm, nil
|
||||
}
|
||||
|
||||
// Check 检查用户是否拥有特定权限。
|
||||
// 权限检查优先级:admin → 全权限 > plus → 全权限 > user → 查表
|
||||
func (s *PermissionService) Check(ctx context.Context, userID, role, tier, permissionKey string) bool {
|
||||
// admin 拥有所有权限
|
||||
if role == "admin" {
|
||||
return true
|
||||
}
|
||||
|
||||
// plus 用户拥有所有权限
|
||||
if tier == "plus" {
|
||||
return true
|
||||
}
|
||||
|
||||
// free 用户查表
|
||||
perm, err := s.GetByUserID(ctx, userID)
|
||||
if err != nil || perm == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
permMap := perm.PermissionMap()
|
||||
hasPermission, ok := permMap[permissionKey]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return hasPermission
|
||||
}
|
||||
|
||||
// Update 更新用户的权限。
|
||||
func (s *PermissionService) Update(ctx context.Context, userID string, updates map[string]bool) error {
|
||||
// 确保权限记录存在
|
||||
if _, err := s.EnsureForUser(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Permission.Update(ctx, userID, updates)
|
||||
}
|
||||
|
||||
// ResetToDefault 将用户权限重置为默认值。
|
||||
func (s *PermissionService) ResetToDefault(ctx context.Context, userID string) error {
|
||||
defaultPerm := model.NewDefaultPermission(userID)
|
||||
return s.repo.Permission.Upsert(ctx, defaultPerm)
|
||||
}
|
||||
|
||||
// GetPermissionMap 获取用户权限的 map 表示。
|
||||
func (s *PermissionService) GetPermissionMap(ctx context.Context, userID string) (map[string]bool, error) {
|
||||
perm, err := s.GetByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return perm.PermissionMap(), nil
|
||||
}
|
||||
|
||||
// IsSuperUser 检查用户是否为超级用户(admin 或 plus)。
|
||||
func (s *PermissionService) IsSuperUser(role, tier string) bool {
|
||||
return role == "admin" || tier == "plus"
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
// Package service — qBittorrent 下载适配器。
|
||||
//
|
||||
// QBitAdapter 实现了 DownloadAdapter 接口,通过 qBittorrent WebUI API
|
||||
// 管理下载任务。底层使用与 QBitClient 相同的 HTTP API 调用逻辑。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QBitAdapter 是 qBittorrent 的 DownloadAdapter 实现。
|
||||
type QBitAdapter struct {
|
||||
mu sync.Mutex
|
||||
cfg DownloadClientConfig
|
||||
client *http.Client
|
||||
LoggedIn bool
|
||||
}
|
||||
|
||||
// NewQBitAdapter 创建新的 qBittorrent 适配器。
|
||||
func NewQBitAdapter() *QBitAdapter {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &QBitAdapter{
|
||||
client: &http.Client{Jar: jar, Timeout: 20 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize 配置并初始化 qBittorrent 连接。
|
||||
func (a *QBitAdapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.cfg = cfg
|
||||
a.LoggedIn = false
|
||||
jar, _ := cookiejar.New(nil)
|
||||
a.client.Jar = jar
|
||||
return a.loginLocked(ctx)
|
||||
}
|
||||
|
||||
// Ping 测试连接。
|
||||
func (a *QBitAdapter) Ping(ctx context.Context) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.loginLocked(ctx)
|
||||
}
|
||||
|
||||
// AddTorrent 通过 URL 添加种子。
|
||||
func (a *QBitAdapter) AddTorrent(ctx context.Context, torrentURL, savePath string) (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if err := a.ensureAuthLocked(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
_ = w.WriteField("urls", torrentURL)
|
||||
if savePath != "" {
|
||||
_ = w.WriteField("savepath", savePath)
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
baseURL := strings.TrimRight(a.cfg.Host, "/")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/api/v2/torrents/add", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("Referer", baseURL)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("qbittorrent add torrent: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// AddMagnet 通过磁力链接添加种子。
|
||||
func (a *QBitAdapter) AddMagnet(ctx context.Context, magnet, savePath string) (string, error) {
|
||||
return a.AddTorrent(ctx, magnet, savePath)
|
||||
}
|
||||
|
||||
// Pause 暂停种子。
|
||||
func (a *QBitAdapter) Pause(ctx context.Context, hash string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if err := a.ensureAuthLocked(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
baseURL := strings.TrimRight(a.cfg.Host, "/")
|
||||
form := url.Values{}
|
||||
form.Set("hashes", hash)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/api/v2/torrents/pause", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", baseURL)
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("qbittorrent pause: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resume 恢复种子。
|
||||
func (a *QBitAdapter) Resume(ctx context.Context, hash string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if err := a.ensureAuthLocked(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
baseURL := strings.TrimRight(a.cfg.Host, "/")
|
||||
form := url.Values{}
|
||||
form.Set("hashes", hash)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/api/v2/torrents/resume", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", baseURL)
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("qbittorrent resume: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove 删除种子。
|
||||
func (a *QBitAdapter) Remove(ctx context.Context, hash string, deleteFiles bool) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if err := a.ensureAuthLocked(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
baseURL := strings.TrimRight(a.cfg.Host, "/")
|
||||
form := url.Values{}
|
||||
form.Set("hashes", hash)
|
||||
if deleteFiles {
|
||||
form.Set("deleteFiles", "true")
|
||||
} else {
|
||||
form.Set("deleteFiles", "false")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/api/v2/torrents/delete", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", baseURL)
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("qbittorrent delete: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 列出种子。
|
||||
func (a *QBitAdapter) List(ctx context.Context, filter string) ([]TorrentInfo, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if err := a.ensureAuthLocked(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseURL := strings.TrimRight(a.cfg.Host, "/")
|
||||
u := baseURL + "/api/v2/torrents/info"
|
||||
if filter != "" {
|
||||
u += "?filter=" + url.QueryEscape(filter)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Referer", baseURL)
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("qbittorrent list: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// qBittorrent 返回的字段名与 TorrentInfo 不同,需要转换
|
||||
type qbTorrent struct {
|
||||
Hash string `json:"hash"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Progress float32 `json:"progress"`
|
||||
DLSpeed int64 `json:"dlspeed"`
|
||||
UPSpeed int64 `json:"upspeed"`
|
||||
NumSeeds int `json:"num_seeds"`
|
||||
NumLeechs int `json:"num_leechs"`
|
||||
Size int64 `json:"size"`
|
||||
SavePath string `json:"save_path"`
|
||||
AddedOn int64 `json:"added_on"`
|
||||
Category string `json:"category"`
|
||||
Tags string `json:"tags"`
|
||||
}
|
||||
|
||||
var qbList []qbTorrent
|
||||
if err := json.NewDecoder(resp.Body).Decode(&qbList); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]TorrentInfo, 0, len(qbList))
|
||||
for _, t := range qbList {
|
||||
result = append(result, TorrentInfo{
|
||||
Hash: t.Hash,
|
||||
Name: t.Name,
|
||||
Size: t.Size,
|
||||
Progress: float64(t.Progress),
|
||||
DLSpeed: t.DLSpeed,
|
||||
UPSpeed: t.UPSpeed,
|
||||
State: t.State,
|
||||
SavePath: t.SavePath,
|
||||
NumSeeds: t.NumSeeds,
|
||||
NumLeechs: t.NumLeechs,
|
||||
AddedOn: time.Unix(t.AddedOn, 0),
|
||||
Category: t.Category,
|
||||
Tags: t.Tags,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetInfo 获取单个种子信息。
|
||||
func (a *QBitAdapter) GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) {
|
||||
list, err := a.List(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range list {
|
||||
if t.Hash == hash {
|
||||
return &t, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("torrent %s not found", hash)
|
||||
}
|
||||
|
||||
// loginLocked 执行登录(调用者必须持有锁)。
|
||||
func (a *QBitAdapter) loginLocked(ctx context.Context) error {
|
||||
if a.cfg.Host == "" {
|
||||
return fmt.Errorf("qbittorrent host not configured")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("username", a.cfg.Username)
|
||||
form.Set("password", a.cfg.Password)
|
||||
baseURL := strings.TrimRight(a.cfg.Host, "/")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
baseURL+"/api/v2/auth/login", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", baseURL)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 || strings.TrimSpace(string(body)) != "Ok." {
|
||||
return fmt.Errorf("qbittorrent login failed: %s", strings.TrimSpace(string(body)))
|
||||
}
|
||||
a.LoggedIn = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAuthLocked 确保已认证(调用者必须持有锁)。
|
||||
func (a *QBitAdapter) ensureAuthLocked(ctx context.Context) error {
|
||||
if a.LoggedIn {
|
||||
return nil
|
||||
}
|
||||
return a.loginLocked(ctx)
|
||||
}
|
||||
|
||||
// --- 为了与现有的 QBitClient 兼容,添加转换辅助函数 ---
|
||||
|
||||
// QBitTorrentToInfo 将旧的 QBitTorrent 转换为新的 TorrentInfo。
|
||||
func QBitTorrentToInfo(q QBitTorrent) TorrentInfo {
|
||||
return TorrentInfo{
|
||||
Hash: q.Hash,
|
||||
Name: q.Name,
|
||||
Size: q.Size,
|
||||
Progress: float64(q.Progress),
|
||||
DLSpeed: q.DLSpeed,
|
||||
UPSpeed: q.UpSpeed,
|
||||
State: q.State,
|
||||
SavePath: q.SavePath,
|
||||
NumSeeds: q.NumSeeds,
|
||||
NumLeechs: q.NumLeech,
|
||||
}
|
||||
}
|
||||
|
||||
// TorrentInfoToQBit 将 TorrentInfo 转换回旧的 QBitTorrent 格式(兼容性)。
|
||||
func TorrentInfoToQBit(t TorrentInfo) QBitTorrent {
|
||||
return QBitTorrent{
|
||||
Hash: t.Hash,
|
||||
Name: t.Name,
|
||||
State: t.State,
|
||||
Progress: float32(t.Progress),
|
||||
DLSpeed: t.DLSpeed,
|
||||
UpSpeed: t.UPSpeed,
|
||||
NumSeeds: t.NumSeeds,
|
||||
NumLeech: t.NumLeechs,
|
||||
Size: t.Size,
|
||||
SavePath: t.SavePath,
|
||||
}
|
||||
}
|
||||
|
||||
// unused import guard
|
||||
var _ = strconv.Itoa
|
||||
+52
-12
@@ -1,11 +1,11 @@
|
||||
// Package service contains the business logic of MediaStationGo. Handlers
|
||||
// deserialize the HTTP request, call into a Service method, then serialize
|
||||
// the response. Services own all cross-cutting policy (auth, scanning,
|
||||
// transcoding, etc.) and never deal with HTTP types directly.
|
||||
// Package service 包含 MediaStationGo 的业务逻辑。
|
||||
// Handler 反序列化 HTTP 请求,调用 Service 方法,然后序列化响应。
|
||||
// Services 拥有所有横切策略(认证、扫描、转码等)且不直接处理 HTTP 类型。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// Container holds every service initialized at startup. Handlers receive a
|
||||
// pointer to it and pick the relevant fields.
|
||||
// Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。
|
||||
type Container struct {
|
||||
Cfg *config.Config
|
||||
Log *zap.Logger
|
||||
Repo *repository.Container
|
||||
WSHub *Hub
|
||||
SSEHub *SSEHub
|
||||
Auth *AuthService
|
||||
Media *MediaService
|
||||
Scan *ScannerService
|
||||
@@ -55,16 +55,26 @@ type Container struct {
|
||||
Notifier *NotifierService
|
||||
Organizer *OrganizerService
|
||||
Douban *DoubanProvider
|
||||
Permission *PermissionService
|
||||
Token *TokenService
|
||||
ApiConfig *ApiConfigService
|
||||
DownloadMgr *DownloadManager
|
||||
Notify *NotifyService
|
||||
Site *SiteService
|
||||
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// New builds the service container.
|
||||
// New 构建服务容器。
|
||||
func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Container {
|
||||
hub := NewHub(log)
|
||||
go hub.Run()
|
||||
|
||||
// 初始化 SSE Hub
|
||||
sseHub := NewSSEHub(log)
|
||||
go sseHub.Run()
|
||||
|
||||
probe := NewFFprobeService(cfg, log)
|
||||
tmdb := NewTMDbProvider(cfg, log)
|
||||
bangumi := NewBangumiProvider(cfg, log)
|
||||
@@ -92,6 +102,14 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
douban := NewDoubanProvider(cfg, log)
|
||||
scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir)
|
||||
|
||||
// 初始化认证相关服务
|
||||
tokenSvc := NewTokenService(cfg, log, repos)
|
||||
permissionSvc := NewPermissionService(cfg, log, repos)
|
||||
apiConfigSvc := NewApiConfigService(cfg, log, repos, crypto)
|
||||
downloadMgr := NewDownloadManager(log, repos, crypto)
|
||||
notifySvc := NewNotifyService(log, repos, crypto)
|
||||
siteSvc := NewSiteService(log, repos, crypto)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Container{
|
||||
@@ -99,7 +117,8 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
Log: log,
|
||||
Repo: repos,
|
||||
WSHub: hub,
|
||||
Auth: NewAuthService(cfg, log, repos),
|
||||
SSEHub: sseHub,
|
||||
Auth: NewAuthService(cfg, log, repos, tokenSvc, permissionSvc),
|
||||
Media: NewMediaService(cfg, log, repos),
|
||||
Scan: scanner,
|
||||
Stream: NewStreamService(cfg, log, repos, transcoder),
|
||||
@@ -134,13 +153,19 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
Notifier: notifier,
|
||||
Organizer: organizer,
|
||||
Douban: douban,
|
||||
Permission: permissionSvc,
|
||||
Token: tokenSvc,
|
||||
ApiConfig: apiConfigSvc,
|
||||
DownloadMgr: downloadMgr,
|
||||
Notify: notifySvc,
|
||||
Site: siteSvc,
|
||||
stopCtx: ctx,
|
||||
stopCancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Boot kicks off background workers (watcher, downloads poller,
|
||||
// subscription scheduler). Called once after AutoMigrate.
|
||||
// Boot 启动后台工作进程(watcher, downloads poller, subscription scheduler)。
|
||||
// 在 AutoMigrate 后调用一次。
|
||||
func (c *Container) Boot() {
|
||||
if err := c.Watcher.Start(c.stopCtx); err != nil {
|
||||
c.Log.Warn("watcher start failed", zap.Error(err))
|
||||
@@ -150,11 +175,17 @@ func (c *Container) Boot() {
|
||||
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
|
||||
c.Log.Warn("api config seed failed", zap.Error(err))
|
||||
}
|
||||
|
||||
// 加载所有已配置的下载客户端
|
||||
if err := c.DownloadMgr.LoadAll(c.stopCtx); err != nil {
|
||||
c.Log.Warn("failed to load download clients", zap.Error(err))
|
||||
}
|
||||
|
||||
// 启动调度器定时任务
|
||||
c.Scheduler.Start(c.stopCtx)
|
||||
}
|
||||
|
||||
// Close releases any resources held by services (websocket hub, ffmpeg
|
||||
// transcodes, fsnotify, background pollers).
|
||||
// Close 释放 services 持有的任何资源(websocket hub, ffmpeg 转码, fsnotify, 后台轮询器)。
|
||||
func (c *Container) Close() {
|
||||
if c.stopCancel != nil {
|
||||
c.stopCancel()
|
||||
@@ -177,4 +208,13 @@ func (c *Container) Close() {
|
||||
if c.WSHub != nil {
|
||||
c.WSHub.Stop()
|
||||
}
|
||||
if c.SSEHub != nil {
|
||||
c.SSEHub.Stop()
|
||||
}
|
||||
if c.Scheduler != nil {
|
||||
c.Scheduler.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// unused guard
|
||||
var _ = time.Now
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
// Package service — 跨站聚合搜索服务.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// SiteSearchService 跨站聚合搜索服务.
|
||||
type SiteSearchService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
site *SiteService
|
||||
}
|
||||
|
||||
// NewSiteSearchService 创建跨站搜索服务.
|
||||
func NewSiteSearchService(log *zap.Logger, repo *repository.Container, siteSvc *SiteService) *SiteSearchService {
|
||||
return &SiteSearchService{log: log, repo: repo, site: siteSvc}
|
||||
}
|
||||
|
||||
// SearchAll 在所有启用的站点中搜索关键字.
|
||||
func (s *SiteSearchService) SearchAll(ctx context.Context, keyword string, page, pageSize int) (*AggregatedResult, error) {
|
||||
sites, err := s.repo.Site.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list enabled sites: %w", err)
|
||||
}
|
||||
|
||||
if len(sites) == 0 {
|
||||
return &AggregatedResult{
|
||||
Keyword: keyword,
|
||||
Items: []TorrentItem{},
|
||||
Total: 0,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return s.SearchSites(ctx, keyword, sites, page, pageSize)
|
||||
}
|
||||
|
||||
// SearchSites 在指定站点中搜索关键字.
|
||||
func (s *SiteSearchService) SearchSites(ctx context.Context, keyword string, sites []model.Site, page, pageSize int) (*AggregatedResult, error) {
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
var allItems []TorrentItem
|
||||
|
||||
for _, site := range sites {
|
||||
wg.Add(1)
|
||||
go func(siteModel model.Site) {
|
||||
defer wg.Done()
|
||||
|
||||
cfg, err := s.site.GetSiteConfig(ctx, siteModel.ID)
|
||||
if err != nil {
|
||||
s.log.Warn("get site config failed", zap.String("site_id", siteModel.ID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
adapter := GetAdapterForType(siteModel.Type)
|
||||
result, err := adapter.Search(ctx, *cfg, keyword, page)
|
||||
if err != nil {
|
||||
s.log.Warn("site search failed",
|
||||
zap.String("site_id", siteModel.ID),
|
||||
zap.String("site_name", siteModel.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
allItems = append(allItems, result.Items...)
|
||||
mu.Unlock()
|
||||
}(site)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(allItems, func(i, j int) bool {
|
||||
if allItems[i].Seeders != allItems[j].Seeders {
|
||||
return allItems[i].Seeders > allItems[j].Seeders
|
||||
}
|
||||
return allItems[i].UploadTime.After(allItems[j].UploadTime)
|
||||
})
|
||||
|
||||
allItems = deduplicateItems(allItems)
|
||||
|
||||
total := len(allItems)
|
||||
start := (page - 1) * pageSize
|
||||
end := start + pageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
return &AggregatedResult{
|
||||
Keyword: keyword,
|
||||
Items: allItems[start:end],
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SearchSite 在单个站点中搜索.
|
||||
func (s *SiteSearchService) SearchSite(ctx context.Context, siteID, keyword string, page int) (*SearchResult, error) {
|
||||
cfg, err := s.site.GetSiteConfig(ctx, siteID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get site config: %w", err)
|
||||
}
|
||||
|
||||
siteModel, err := s.repo.Site.FindByID(ctx, siteID)
|
||||
if err != nil || siteModel == nil {
|
||||
return nil, fmt.Errorf("find site: %w", err)
|
||||
}
|
||||
|
||||
adapter := GetAdapterForType(siteModel.Type)
|
||||
result, err := adapter.Search(ctx, *cfg, keyword, page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search site %s: %w", siteModel.Name, err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// BrowseSite 浏览站点资源.
|
||||
func (s *SiteSearchService) BrowseSite(ctx context.Context, siteID, category string, page int) (*SearchResult, error) {
|
||||
cfg, err := s.site.GetSiteConfig(ctx, siteID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get site config: %w", err)
|
||||
}
|
||||
|
||||
siteModel, err := s.repo.Site.FindByID(ctx, siteID)
|
||||
if err != nil || siteModel == nil {
|
||||
return nil, fmt.Errorf("find site: %w", err)
|
||||
}
|
||||
|
||||
adapter := GetAdapterForType(siteModel.Type)
|
||||
result, err := adapter.Browse(ctx, *cfg, category, page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("browse site %s: %w", siteModel.Name, err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetTorrentDetail 获取种子详情.
|
||||
func (s *SiteSearchService) GetTorrentDetail(ctx context.Context, siteID, torrentID string) (*TorrentDetail, error) {
|
||||
cfg, err := s.site.GetSiteConfig(ctx, siteID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get site config: %w", err)
|
||||
}
|
||||
|
||||
siteModel, err := s.repo.Site.FindByID(ctx, siteID)
|
||||
if err != nil || siteModel == nil {
|
||||
return nil, fmt.Errorf("find site: %w", err)
|
||||
}
|
||||
|
||||
adapter := GetAdapterForType(siteModel.Type)
|
||||
detail, err := adapter.GetDetail(ctx, *cfg, torrentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get detail from %s: %w", siteModel.Name, err)
|
||||
}
|
||||
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// AggregatedResult 聚合搜索结果.
|
||||
type AggregatedResult struct {
|
||||
Keyword string `json:"keyword"`
|
||||
Items []TorrentItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// deduplicateItems 通过标题相似性去重.
|
||||
func deduplicateItems(items []TorrentItem) []TorrentItem {
|
||||
seen := make(map[string]bool)
|
||||
result := make([]TorrentItem, 0, len(items))
|
||||
|
||||
for _, item := range items {
|
||||
key := normalizeTitle(item.Title)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// normalizeTitle 标题标准化.
|
||||
func normalizeTitle(title string) string {
|
||||
title = strings.ToLower(strings.TrimSpace(title))
|
||||
title = strings.ReplaceAll(title, ".", " ")
|
||||
title = strings.ReplaceAll(title, "_", " ")
|
||||
title = strings.ReplaceAll(title, "-", " ")
|
||||
for strings.Contains(title, " ") {
|
||||
title = strings.ReplaceAll(title, " ", " ")
|
||||
}
|
||||
return strings.TrimSpace(title)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// Package service — PT 站点管理服务。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// 站点管理错误码。
|
||||
var (
|
||||
ErrSiteNotFound = errors.New("site not found")
|
||||
ErrSiteAuthFailed = errors.New("site authentication failed")
|
||||
ErrSiteTypeInvalid = errors.New("invalid site type")
|
||||
ErrSiteAuthInvalid = errors.New("invalid auth type")
|
||||
)
|
||||
|
||||
// SiteService 站点管理服务。
|
||||
type SiteService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
crypto *CryptoService
|
||||
}
|
||||
|
||||
// NewSiteService 创建站点管理服务。
|
||||
func NewSiteService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *SiteService {
|
||||
return &SiteService{log: log, repo: repo, crypto: crypto}
|
||||
}
|
||||
|
||||
// Create 创建站点,加密敏感字段。
|
||||
func (s *SiteService) Create(ctx context.Context, site *model.Site) (*model.Site, error) {
|
||||
if !isValidSiteType(site.Type) {
|
||||
return nil, ErrSiteTypeInvalid
|
||||
}
|
||||
if !isValidAuthType(site.AuthType) {
|
||||
return nil, ErrSiteAuthInvalid
|
||||
}
|
||||
|
||||
// 加密敏感字段
|
||||
s.encryptSite(site)
|
||||
|
||||
if err := s.repo.Site.Create(ctx, site); err != nil {
|
||||
s.log.Error("create site failed", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return site, nil
|
||||
}
|
||||
|
||||
// GetByID 获取站点(敏感字段解密)。
|
||||
func (s *SiteService) GetByID(ctx context.Context, id string) (*model.Site, error) {
|
||||
site, err := s.repo.Site.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if site == nil {
|
||||
return nil, ErrSiteNotFound
|
||||
}
|
||||
|
||||
s.decryptSite(site)
|
||||
return site, nil
|
||||
}
|
||||
|
||||
// List 获取所有站点(不含敏感字段)。
|
||||
func (s *SiteService) List(ctx context.Context) ([]model.Site, error) {
|
||||
sites, err := s.repo.Site.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sites, nil
|
||||
}
|
||||
|
||||
// Update 更新站点。
|
||||
func (s *SiteService) Update(ctx context.Context, site *model.Site) (*model.Site, error) {
|
||||
existing, err := s.repo.Site.FindByID(ctx, site.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
return nil, ErrSiteNotFound
|
||||
}
|
||||
|
||||
if !isValidSiteType(site.Type) {
|
||||
return nil, ErrSiteTypeInvalid
|
||||
}
|
||||
if !isValidAuthType(site.AuthType) {
|
||||
return nil, ErrSiteAuthInvalid
|
||||
}
|
||||
|
||||
s.encryptSite(site)
|
||||
|
||||
if err := s.repo.Site.Update(ctx, site); err != nil {
|
||||
s.log.Error("update site failed", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return site, nil
|
||||
}
|
||||
|
||||
// Delete 删除站点。
|
||||
func (s *SiteService) Delete(ctx context.Context, id string) error {
|
||||
existing, err := s.repo.Site.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return ErrSiteNotFound
|
||||
}
|
||||
return s.repo.Site.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// Authenticate 测试站点认证。
|
||||
func (s *SiteService) Authenticate(ctx context.Context, id string) error {
|
||||
site, err := s.repo.Site.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if site == nil {
|
||||
return ErrSiteNotFound
|
||||
}
|
||||
|
||||
cfg, err := s.toSiteConfig(site)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
adapter := GetAdapterForType(site.Type)
|
||||
if err := adapter.Authenticate(ctx, *cfg); err != nil {
|
||||
// 更新错误状态
|
||||
now := time.Now()
|
||||
site.LastError = err.Error()
|
||||
site.LastCheckAt = &now
|
||||
_ = s.repo.Site.Update(ctx, site)
|
||||
return ErrSiteAuthFailed
|
||||
}
|
||||
|
||||
// 清除错误状态
|
||||
now := time.Now()
|
||||
site.LastError = ""
|
||||
site.LastCheckAt = &now
|
||||
_ = s.repo.Site.Update(ctx, site)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSiteConfig 获取解密后的站点配置(供内部使用)。
|
||||
func (s *SiteService) GetSiteConfig(ctx context.Context, id string) (*SiteConfig, error) {
|
||||
site, err := s.repo.Site.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if site == nil {
|
||||
return nil, ErrSiteNotFound
|
||||
}
|
||||
return s.toSiteConfig(site)
|
||||
}
|
||||
|
||||
// encryptSite 加密站点敏感字段。
|
||||
func (s *SiteService) encryptSite(site *model.Site) {
|
||||
if site.Cookie != "" {
|
||||
site.Cookie = s.crypto.Encrypt(site.Cookie)
|
||||
}
|
||||
if site.APIKey != "" {
|
||||
site.APIKey = s.crypto.Encrypt(site.APIKey)
|
||||
}
|
||||
if site.AuthHeader != "" {
|
||||
site.AuthHeader = s.crypto.Encrypt(site.AuthHeader)
|
||||
}
|
||||
if site.Extra != "" {
|
||||
site.Extra = s.crypto.Encrypt(site.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
// decryptSite 解密站点敏感字段。
|
||||
func (s *SiteService) decryptSite(site *model.Site) {
|
||||
if site.Cookie != "" {
|
||||
site.Cookie = s.crypto.Decrypt(site.Cookie)
|
||||
}
|
||||
if site.APIKey != "" {
|
||||
site.APIKey = s.crypto.Decrypt(site.APIKey)
|
||||
}
|
||||
if site.AuthHeader != "" {
|
||||
site.AuthHeader = s.crypto.Decrypt(site.AuthHeader)
|
||||
}
|
||||
if site.Extra != "" {
|
||||
site.Extra = s.crypto.Decrypt(site.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
// toSiteConfig 将 model.Site 转换为 SiteConfig(解密后)。
|
||||
func (s *SiteService) toSiteConfig(site *model.Site) (*SiteConfig, error) {
|
||||
cfg := &SiteConfig{
|
||||
Name: site.Name,
|
||||
Type: site.Type,
|
||||
URL: strings.TrimRight(site.URL, "/"),
|
||||
AuthType: site.AuthType,
|
||||
Extra: map[string]string{},
|
||||
}
|
||||
|
||||
// 解密
|
||||
if site.Cookie != "" {
|
||||
cfg.Cookie = s.crypto.Decrypt(site.Cookie)
|
||||
}
|
||||
if site.APIKey != "" {
|
||||
cfg.APIKey = s.crypto.Decrypt(site.APIKey)
|
||||
}
|
||||
if site.AuthHeader != "" {
|
||||
cfg.AuthHeader = s.crypto.Decrypt(site.AuthHeader)
|
||||
}
|
||||
if site.Extra != "" {
|
||||
dec := s.crypto.Decrypt(site.Extra)
|
||||
if dec != "" {
|
||||
if err := json.Unmarshal([]byte(dec), &cfg.Extra); err != nil {
|
||||
s.log.Warn("parse site extra config failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// isValidSiteType 检查站点类型是否有效。
|
||||
func isValidSiteType(siteType string) bool {
|
||||
for _, t := range model.SiteTypes() {
|
||||
if t == siteType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidAuthType 检查认证方式是否有效。
|
||||
func isValidAuthType(authType string) bool {
|
||||
for _, t := range model.AuthTypes() {
|
||||
if t == authType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Package service — SSE (Server-Sent Events) 事件流服务。
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SSEHub 管理 SSE 客户端连接和事件广播。
|
||||
type SSEHub struct {
|
||||
clients map[chan SSEEvent]bool
|
||||
broadcast chan SSEEvent
|
||||
register chan chan SSEEvent
|
||||
unregister chan chan SSEEvent
|
||||
log *zap.Logger
|
||||
tickets map[string]*sseTicket
|
||||
ticketMu sync.RWMutex
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
type SSEEvent struct {
|
||||
Type string `json:"type"`
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// SSEEvent 事件类型常量。
|
||||
const (
|
||||
EventTypeScan = "scan"
|
||||
EventTypeDownload = "download"
|
||||
EventTypeSubscribe = "subscribe"
|
||||
EventTypeTask = "task"
|
||||
EventTypeSystem = "system"
|
||||
EventTypeAuth = "auth"
|
||||
)
|
||||
|
||||
// sseTicket 是一次性 OTP 票据。
|
||||
type sseTicket struct {
|
||||
UserID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// NewSSEHub 创建 SSE Hub 实例。
|
||||
func NewSSEHub(log *zap.Logger) *SSEHub {
|
||||
return &SSEHub{
|
||||
clients: make(map[chan SSEEvent]bool),
|
||||
broadcast: make(chan SSEEvent, 256),
|
||||
register: make(chan chan SSEEvent),
|
||||
unregister: make(chan chan SSEEvent),
|
||||
tickets: make(map[string]*sseTicket),
|
||||
log: log,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Run 启动 SSE Hub 的事件循环。
|
||||
func (h *SSEHub) Run() {
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.clients[client] = true
|
||||
h.log.Debug("SSE client connected", zap.Int("total", len(h.clients)))
|
||||
|
||||
case client := <-h.unregister:
|
||||
if _, ok := h.clients[client]; ok {
|
||||
delete(h.clients, client)
|
||||
close(client)
|
||||
h.log.Debug("SSE client disconnected", zap.Int("total", len(h.clients)))
|
||||
}
|
||||
|
||||
case event := <-h.broadcast:
|
||||
h.distribute(event)
|
||||
|
||||
case <-h.stopCh:
|
||||
h.closeAll()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop 停止 SSE Hub。
|
||||
func (h *SSEHub) Stop() {
|
||||
close(h.stopCh)
|
||||
}
|
||||
|
||||
// ClientChannel SSE 客户端通道包装器。
|
||||
type ClientChannel struct {
|
||||
Ch chan SSEEvent
|
||||
}
|
||||
|
||||
// Subscribe 注册一个新的 SSE 客户端,返回 SSE 客户端包装器。
|
||||
func (h *SSEHub) Subscribe() *ClientChannel {
|
||||
ch := make(chan SSEEvent, 100)
|
||||
h.register <- ch
|
||||
return &ClientChannel{Ch: ch}
|
||||
}
|
||||
|
||||
// Unsubscribe 取消注册 SSE 客户端。
|
||||
func (h *SSEHub) Unsubscribe(client *ClientChannel) {
|
||||
if client != nil && client.Ch != nil {
|
||||
h.unregister <- client.Ch
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast 向所有连接的客户端广播事件。
|
||||
func (h *SSEHub) Broadcast(eventType string, payload interface{}) {
|
||||
event := SSEEvent{
|
||||
Type: eventType,
|
||||
Payload: payload,
|
||||
}
|
||||
select {
|
||||
case h.broadcast <- event:
|
||||
default:
|
||||
h.log.Warn("SSE broadcast queue full, dropping event", zap.String("type", eventType))
|
||||
}
|
||||
}
|
||||
|
||||
// SendToUser 向指定用户发送事件(通过 UserID 匹配)。
|
||||
// 注意:此方法需要在客户端连接时关联 UserID。
|
||||
func (h *SSEHub) SendToUser(userID string, eventType string, payload interface{}) {
|
||||
// 目前通过广播实现,未来可扩展为按用户分组
|
||||
h.Broadcast(eventType, payload)
|
||||
}
|
||||
|
||||
// distribute 将事件分发给所有客户端。
|
||||
func (h *SSEHub) distribute(event SSEEvent) {
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
h.log.Error("failed to marshal SSE event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
for client := range h.clients {
|
||||
select {
|
||||
case client <- event:
|
||||
default:
|
||||
// 客户端通道已满,跳过
|
||||
h.log.Warn("SSE client buffer full", zap.String("event", string(data)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// closeAll 关闭所有客户端连接。
|
||||
func (h *SSEHub) closeAll() {
|
||||
for client := range h.clients {
|
||||
close(client)
|
||||
}
|
||||
h.clients = make(map[chan SSEEvent]bool)
|
||||
}
|
||||
|
||||
// GenerateTicket 生成一次性 SSE 连接票据(用于无 JWT 场景下的安全连接)。
|
||||
func (h *SSEHub) GenerateTicket(userID string) (string, error) {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ticket := hex.EncodeToString(buf)
|
||||
|
||||
h.ticketMu.Lock()
|
||||
defer h.ticketMu.Unlock()
|
||||
|
||||
h.tickets[ticket] = &sseTicket{
|
||||
UserID: userID,
|
||||
ExpiresAt: time.Now().Add(10 * time.Second),
|
||||
}
|
||||
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// ValidateTicket 验证 SSE 连接票据,返回关联的用户 ID。
|
||||
func (h *SSEHub) ValidateTicket(ticket string) (string, error) {
|
||||
h.ticketMu.Lock()
|
||||
defer h.ticketMu.Unlock()
|
||||
|
||||
t, ok := h.tickets[ticket]
|
||||
if !ok {
|
||||
return "", ErrInvalidTicket
|
||||
}
|
||||
|
||||
if time.Now().After(t.ExpiresAt) {
|
||||
delete(h.tickets, ticket)
|
||||
return "", ErrTicketExpired
|
||||
}
|
||||
|
||||
userID := t.UserID
|
||||
delete(h.tickets, ticket)
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// CleanupTickets 清理过期的票据。
|
||||
func (h *SSEHub) CleanupTickets() {
|
||||
h.ticketMu.Lock()
|
||||
defer h.ticketMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for ticket, t := range h.tickets {
|
||||
if now.After(t.ExpiresAt) {
|
||||
delete(h.tickets, ticket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SSE Hub 错误定义。
|
||||
var (
|
||||
ErrInvalidTicket = &SSEError{Message: "invalid ticket"}
|
||||
ErrTicketExpired = &SSEError{Message: "ticket expired"}
|
||||
)
|
||||
|
||||
// SSEError SSE 相关错误。
|
||||
type SSEError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *SSEError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// ClientCount 返回当前连接的客户端数量。
|
||||
func (h *SSEHub) ClientCount() int {
|
||||
h.ticketMu.RLock()
|
||||
defer h.ticketMu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// Package service — STRM 文件管理服务。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
// STRM 错误定义。
|
||||
var (
|
||||
ErrSTRMNotFound = errors.New("strm record not found")
|
||||
ErrSTRMProtocolInvalid = errors.New("invalid strm protocol")
|
||||
ErrSTRMURLInvalid = errors.New("invalid strm url")
|
||||
)
|
||||
|
||||
// STRMService STRM 文件管理服务。
|
||||
type STRMService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewSTRMService 创建 STRM 服务。
|
||||
func NewSTRMService(log *zap.Logger, repo *repository.Container, cfg *config.Config) *STRMService {
|
||||
return &STRMService{log: log, repo: repo, cfg: cfg}
|
||||
}
|
||||
|
||||
// Create 创建 STRM 记录。
|
||||
func (s *STRMService) Create(ctx context.Context, record *model.STRMRecord) (*model.STRMRecord, error) {
|
||||
if err := s.validateSTRM(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.repo.STRM.Create(ctx, record); err != nil {
|
||||
s.log.Error("create strm failed", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// CreateBatch 批量创建 STRM 记录。
|
||||
func (s *STRMService) CreateBatch(ctx context.Context, records []model.STRMRecord) (int, error) {
|
||||
created := 0
|
||||
for i := range records {
|
||||
if err := s.validateSTRM(&records[i]); err != nil {
|
||||
s.log.Warn("skip invalid strm record",
|
||||
zap.String("title", records[i].Title),
|
||||
zap.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
created++
|
||||
}
|
||||
|
||||
validRecords := make([]model.STRMRecord, 0, created)
|
||||
for _, r := range records {
|
||||
if model.IsAllowedProtocol(r.Protocol) && r.URL != "" {
|
||||
validRecords = append(validRecords, r)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validRecords) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if err := s.repo.STRM.CreateBatch(ctx, validRecords); err != nil {
|
||||
s.log.Error("batch create strm failed", zap.Error(err))
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return len(validRecords), nil
|
||||
}
|
||||
|
||||
// GetByID 获取 STRM 记录。
|
||||
func (s *STRMService) GetByID(ctx context.Context, id string) (*model.STRMRecord, error) {
|
||||
record, err := s.repo.STRM.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, ErrSTRMNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// List 列出 STRM 记录(支持筛选和分页)。
|
||||
func (s *STRMService) List(ctx context.Context, filters map[string]string, page, pageSize int) ([]model.STRMRecord, int64, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
records, total, err := s.repo.STRM.List(ctx, filters, offset, pageSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return records, total, nil
|
||||
}
|
||||
|
||||
// Update 更新 STRM 记录。
|
||||
func (s *STRMService) Update(ctx context.Context, record *model.STRMRecord) (*model.STRMRecord, error) {
|
||||
existing, err := s.repo.STRM.FindByID(ctx, record.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
return nil, ErrSTRMNotFound
|
||||
}
|
||||
|
||||
if record.Protocol != "" {
|
||||
if !model.IsAllowedProtocol(record.Protocol) {
|
||||
return nil, ErrSTRMProtocolInvalid
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.repo.STRM.Update(ctx, record); err != nil {
|
||||
s.log.Error("update strm failed", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// Delete 删除 STRM 记录。
|
||||
func (s *STRMService) Delete(ctx context.Context, id string) error {
|
||||
existing, err := s.repo.STRM.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return ErrSTRMNotFound
|
||||
}
|
||||
return s.repo.STRM.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// GetProtocols 获取支持的协议列表。
|
||||
func (s *STRMService) GetProtocols() []string {
|
||||
return model.AllowedSTRMProtocols
|
||||
}
|
||||
|
||||
// ProxySTRM 代理访问 STRM 资源。
|
||||
// 支持 Range 请求(206 Partial Content)。
|
||||
func (s *STRMService) ProxySTRM(ctx context.Context, id string, req *http.Request, w http.ResponseWriter) error {
|
||||
record, err := s.repo.STRM.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if record == nil {
|
||||
return ErrSTRMNotFound
|
||||
}
|
||||
|
||||
if !model.IsAllowedProtocol(record.Protocol) {
|
||||
return ErrSTRMProtocolInvalid
|
||||
}
|
||||
|
||||
// 创建代理请求
|
||||
proxyReq, err := http.NewRequestWithContext(ctx, req.Method, record.URL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create proxy request: %w", err)
|
||||
}
|
||||
|
||||
// 复制 Range 等关键请求头
|
||||
for _, header := range []string{
|
||||
"Range", "If-Range", "If-Match", "If-None-Match",
|
||||
"If-Modified-Since", "If-Unmodified-Since",
|
||||
"Accept", "Accept-Encoding", "Accept-Language",
|
||||
} {
|
||||
if v := req.Header.Get(header); v != "" {
|
||||
proxyReq.Header.Set(header, v)
|
||||
}
|
||||
}
|
||||
|
||||
// 对 alist/webdav 协议可能需要特殊处理认证
|
||||
if record.Protocol == "alist" || record.Protocol == "alists" {
|
||||
// alist 协议可以直接访问,无需额外认证
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(proxyReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("proxy request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 复制响应头
|
||||
for _, header := range []string{
|
||||
"Content-Type", "Content-Length", "Content-Range",
|
||||
"Accept-Ranges", "Last-Modified", "ETag",
|
||||
"Cache-Control", "Content-Disposition",
|
||||
} {
|
||||
if v := resp.Header.Get(header); v != "" {
|
||||
w.Header().Set(header, v)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, err = io.Copy(w, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
// validateSTRM 验证 STRM 记录。
|
||||
func (s *STRMService) validateSTRM(record *model.STRMRecord) error {
|
||||
if record.Title == "" {
|
||||
return errors.New("title is required")
|
||||
}
|
||||
if record.URL == "" {
|
||||
return ErrSTRMURLInvalid
|
||||
}
|
||||
if !model.IsAllowedProtocol(record.Protocol) {
|
||||
return ErrSTRMProtocolInvalid
|
||||
}
|
||||
|
||||
// 标准化协议名
|
||||
record.Protocol = strings.ToLower(record.Protocol)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByMediaID 获取关联到指定媒体的 STRM 记录。
|
||||
func (s *STRMService) ListByMediaID(ctx context.Context, mediaID string) ([]model.STRMRecord, error) {
|
||||
return s.repo.STRM.FindByMediaID(ctx, mediaID)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Package service — 双令牌认证服务。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
// AccessTokenDuration Access Token 有效期(60分钟)
|
||||
AccessTokenDuration = 60 * time.Minute
|
||||
// RefreshTokenDuration Refresh Token 有效期(30天)
|
||||
RefreshTokenDuration = 30 * 24 * time.Hour
|
||||
// RefreshTokenLength Refresh Token 随机字节长度
|
||||
RefreshTokenLength = 32
|
||||
)
|
||||
|
||||
// Claims 是 JWT 载荷(复制自 middleware 以避免循环导入)。
|
||||
type Claims struct {
|
||||
UserID string `json:"uid"`
|
||||
Role string `json:"role"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// TokenService 处理双令牌认证(Access Token + Refresh Token)。
|
||||
type TokenService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
}
|
||||
|
||||
// NewTokenService 创建令牌服务实例。
|
||||
func NewTokenService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *TokenService {
|
||||
return &TokenService{cfg: cfg, log: log, repo: repo}
|
||||
}
|
||||
|
||||
// TokenPair 包含访问令牌和刷新令牌。
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"` // 秒
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// TokenService 错误定义。
|
||||
var (
|
||||
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
ErrTokenRevoked = errors.New("token revoked")
|
||||
)
|
||||
|
||||
// IssuePair 为用户签发新的令牌对。
|
||||
func (s *TokenService) IssuePair(ctx context.Context, userID, role, tier string) (*TokenPair, error) {
|
||||
// 生成 Access Token
|
||||
accessToken, err := s.issueAccessToken(userID, role, tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 生成 Refresh Token
|
||||
refreshToken, err := s.generateRefreshToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 存储 Refresh Token 哈希
|
||||
tokenHash := repository.HashToken(refreshToken)
|
||||
rt := &model.RefreshToken{
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: time.Now().Add(RefreshTokenDuration),
|
||||
}
|
||||
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int64(AccessTokenDuration.Seconds()),
|
||||
TokenType: "Bearer",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// issueAccessToken 签发 JWT Access Token(HS256,60分钟有效期)。
|
||||
func (s *TokenService) issueAccessToken(userID, role, tier string) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
Tier: tier,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
|
||||
Issuer: "mediastationgo",
|
||||
Subject: userID,
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return t.SignedString([]byte(s.cfg.Secrets.JWTSecret))
|
||||
}
|
||||
|
||||
// generateRefreshToken 生成安全的随机 Refresh Token。
|
||||
func (s *TokenService) generateRefreshToken() (string, error) {
|
||||
buf := make([]byte, RefreshTokenLength)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// Refresh 使用 Refresh Token 轮换获取新的令牌对。
|
||||
func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||
tokenHash := repository.HashToken(refreshToken)
|
||||
|
||||
// 查找 Refresh Token 记录
|
||||
rt, err := s.repo.RefreshToken.FindByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rt == nil {
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
|
||||
// 检查是否已撤销
|
||||
if rt.Revoked {
|
||||
return nil, ErrTokenRevoked
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if rt.IsExpired() {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
user, err := s.repo.User.FindByID(ctx, rt.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
|
||||
// 撤销旧的 Refresh Token
|
||||
if err := s.repo.RefreshToken.Revoke(ctx, tokenHash); err != nil {
|
||||
s.log.Warn("failed to revoke old refresh token", zap.Error(err))
|
||||
}
|
||||
|
||||
// 签发新的令牌对
|
||||
return s.IssuePair(ctx, user.ID, user.Role, user.Tier)
|
||||
}
|
||||
|
||||
// RevokeAll 撤销用户的所有 Refresh Token(用于登出)。
|
||||
func (s *TokenService) RevokeAll(ctx context.Context, userID string) error {
|
||||
return s.repo.RefreshToken.RevokeByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// ValidateAccessToken 验证 Access Token 并返回 Claims。
|
||||
func (s *TokenService) ValidateAccessToken(tokenString string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(s.cfg.Secrets.JWTSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// CleanupExpired 清理过期的 Refresh Token。
|
||||
func (s *TokenService) CleanupExpired(ctx context.Context) error {
|
||||
return s.repo.RefreshToken.DeleteExpired(ctx)
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
// Package service — Transmission 下载适配器。
|
||||
//
|
||||
// TransmissionAdapter 实现了 DownloadAdapter 接口,通过 Transmission RPC API
|
||||
// 管理下载任务。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// transmissionRPCRequest 是 Transmission RPC 请求的通用结构。
|
||||
type transmissionRPCRequest struct {
|
||||
Method string `json:"method"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
Tag int `json:"tag,omitempty"`
|
||||
}
|
||||
|
||||
// transmissionRPCResponse 是 Transmission RPC 响应的通用结构。
|
||||
type transmissionRPCResponse struct {
|
||||
Result string `json:"result"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
Tag int `json:"tag"`
|
||||
}
|
||||
|
||||
// TransmissionAdapter 是 Transmission 的 DownloadAdapter 实现。
|
||||
type TransmissionAdapter struct {
|
||||
mu sync.Mutex
|
||||
cfg DownloadClientConfig
|
||||
client *http.Client
|
||||
tag int
|
||||
sessionID string
|
||||
}
|
||||
|
||||
// NewTransmissionAdapter 创建新的 Transmission 适配器。
|
||||
func NewTransmissionAdapter() *TransmissionAdapter {
|
||||
return &TransmissionAdapter{
|
||||
client: &http.Client{Timeout: 20 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize 配置并初始化 Transmission RPC 连接。
|
||||
func (a *TransmissionAdapter) Initialize(ctx context.Context, cfg DownloadClientConfig) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.cfg = cfg
|
||||
a.sessionID = ""
|
||||
a.tag = 0
|
||||
return a.pingLocked(ctx)
|
||||
}
|
||||
|
||||
// Ping 测试连接。
|
||||
func (a *TransmissionAdapter) Ping(ctx context.Context) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.pingLocked(ctx)
|
||||
}
|
||||
|
||||
// pingLocked 内部 ping 实现(调用者必须持有锁)。
|
||||
func (a *TransmissionAdapter) pingLocked(ctx context.Context) error {
|
||||
rpcURL := a.cfg.Host
|
||||
if !strings.HasSuffix(rpcURL, "/rpc") && !strings.HasSuffix(rpcURL, "/transmission/rpc") {
|
||||
if !strings.Contains(rpcURL, "/rpc") {
|
||||
rpcURL = strings.TrimRight(rpcURL, "/") + "/transmission/rpc"
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rpcURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.cfg.Username != "" {
|
||||
req.SetBasicAuth(a.cfg.Username, a.cfg.Password)
|
||||
}
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode == 409 {
|
||||
// 正常:需要 CSRF token
|
||||
a.sessionID = resp.Header.Get("X-Transmission-Session-Id")
|
||||
return nil
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("transmission rpc: %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rpcLocked 发送 RPC 请求(调用者必须持有锁)。
|
||||
func (a *TransmissionAdapter) rpcLocked(ctx context.Context, method string, args map[string]interface{}) (*transmissionRPCResponse, error) {
|
||||
rpcURL := a.cfg.Host
|
||||
if !strings.Contains(rpcURL, "/rpc") {
|
||||
rpcURL = strings.TrimRight(rpcURL, "/") + "/transmission/rpc"
|
||||
}
|
||||
|
||||
a.tag++
|
||||
body, err := json.Marshal(transmissionRPCRequest{
|
||||
Method: method,
|
||||
Arguments: args,
|
||||
Tag: a.tag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if a.sessionID != "" {
|
||||
req.Header.Set("X-Transmission-Session-Id", a.sessionID)
|
||||
}
|
||||
if a.cfg.Username != "" {
|
||||
req.SetBasicAuth(a.cfg.Username, a.cfg.Password)
|
||||
}
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 409 {
|
||||
a.sessionID = resp.Header.Get("X-Transmission-Session-Id")
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("transmission rpc error: %d: %s", resp.StatusCode, string(raw))
|
||||
}
|
||||
|
||||
var result transmissionRPCResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Result != "success" {
|
||||
return nil, fmt.Errorf("transmission rpc result: %s", result.Result)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
return nil, fmt.Errorf("transmission: failed after CSRF retry")
|
||||
}
|
||||
|
||||
// AddTorrent 通过 URL 添加种子。
|
||||
func (a *TransmissionAdapter) AddTorrent(ctx context.Context, torrentURL, savePath string) (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
args := map[string]interface{}{"filename": torrentURL}
|
||||
if savePath != "" {
|
||||
args["download-dir"] = savePath
|
||||
}
|
||||
resp, err := a.rpcLocked(ctx, "torrent-add", args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if added, ok := resp.Arguments["torrent-added"].(map[string]interface{}); ok {
|
||||
if hashStr, ok := added["hashString"].(string); ok {
|
||||
return hashStr, nil
|
||||
}
|
||||
}
|
||||
if dup, ok := resp.Arguments["torrent-duplicate"].(map[string]interface{}); ok {
|
||||
if hashStr, ok := dup["hashString"].(string); ok {
|
||||
return hashStr, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// AddMagnet 通过磁力链接添加种子。
|
||||
func (a *TransmissionAdapter) AddMagnet(ctx context.Context, magnet, savePath string) (string, error) {
|
||||
return a.AddTorrent(ctx, magnet, savePath)
|
||||
}
|
||||
|
||||
// Pause 暂停种子。
|
||||
func (a *TransmissionAdapter) Pause(ctx context.Context, hash string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
_, err := a.rpcLocked(ctx, "torrent-stop", map[string]interface{}{
|
||||
"ids": []string{hash},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Resume 恢复种子。
|
||||
func (a *TransmissionAdapter) Resume(ctx context.Context, hash string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
_, err := a.rpcLocked(ctx, "torrent-start", map[string]interface{}{
|
||||
"ids": []string{hash},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove 删除种子。
|
||||
func (a *TransmissionAdapter) Remove(ctx context.Context, hash string, deleteFiles bool) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
_, err := a.rpcLocked(ctx, "torrent-remove", map[string]interface{}{
|
||||
"ids": []string{hash},
|
||||
"delete-local-data": deleteFiles,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// List 列出种子。
|
||||
func (a *TransmissionAdapter) List(ctx context.Context, filter string) ([]TorrentInfo, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
args := map[string]interface{}{
|
||||
"fields": []string{
|
||||
"hashString", "name", "totalSize", "percentDone",
|
||||
"rateDownload", "rateUpload", "status", "downloadDir",
|
||||
"peersSendingToUs", "peersGettingFromUs", "addedDate",
|
||||
"labels", "isStalled",
|
||||
},
|
||||
}
|
||||
resp, err := a.rpcLocked(ctx, "torrent-get", args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
torrentsRaw, ok := resp.Arguments["torrents"].([]interface{})
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := make([]TorrentInfo, 0, len(torrentsRaw))
|
||||
for _, tr := range torrentsRaw {
|
||||
t, ok := tr.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
hash, _ := t["hashString"].(string)
|
||||
name, _ := t["name"].(string)
|
||||
size := toInt64(t["totalSize"])
|
||||
progress := toFloat64(t["percentDone"])
|
||||
dlSpeed := toInt64(t["rateDownload"])
|
||||
upSpeed := toInt64(t["rateUpload"])
|
||||
savePath, _ := t["downloadDir"].(string)
|
||||
numSeeds := int(toInt64(t["peersSendingToUs"]))
|
||||
numLeechs := int(toInt64(t["peersGettingFromUs"]))
|
||||
addedOn := int64(toFloat64(t["addedDate"]))
|
||||
|
||||
// Transmission 状态码转字符串
|
||||
status := int(toFloat64(t["status"]))
|
||||
state := transmissionStateStr(status)
|
||||
|
||||
// 过滤
|
||||
if filter != "" && !strings.EqualFold(state, filter) {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, TorrentInfo{
|
||||
Hash: hash,
|
||||
Name: name,
|
||||
Size: size,
|
||||
Progress: progress * 100,
|
||||
DLSpeed: dlSpeed,
|
||||
UPSpeed: upSpeed,
|
||||
State: state,
|
||||
SavePath: savePath,
|
||||
NumSeeds: numSeeds,
|
||||
NumLeechs: numLeechs,
|
||||
AddedOn: time.Unix(addedOn, 0),
|
||||
Tags: toJSONLabels(t["labels"]),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetInfo 获取单个种子信息。
|
||||
func (a *TransmissionAdapter) GetInfo(ctx context.Context, hash string) (*TorrentInfo, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
args := map[string]interface{}{
|
||||
"ids": []string{hash},
|
||||
"fields": []string{
|
||||
"hashString", "name", "totalSize", "percentDone",
|
||||
"rateDownload", "rateUpload", "status", "downloadDir",
|
||||
"peersSendingToUs", "peersGettingFromUs", "addedDate", "labels",
|
||||
},
|
||||
}
|
||||
resp, err := a.rpcLocked(ctx, "torrent-get", args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
torrentsRaw, ok := resp.Arguments["torrents"].([]interface{})
|
||||
if !ok || len(torrentsRaw) == 0 {
|
||||
return nil, fmt.Errorf("torrent %s not found", hash)
|
||||
}
|
||||
t, ok := torrentsRaw[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("torrent %s: invalid response", hash)
|
||||
}
|
||||
|
||||
status := int(toFloat64(t["status"]))
|
||||
info := &TorrentInfo{
|
||||
Hash: hash,
|
||||
Name: strVal(t["name"]),
|
||||
Size: toInt64(t["totalSize"]),
|
||||
Progress: toFloat64(t["percentDone"]) * 100,
|
||||
DLSpeed: toInt64(t["rateDownload"]),
|
||||
UPSpeed: toInt64(t["rateUpload"]),
|
||||
State: transmissionStateStr(status),
|
||||
SavePath: strVal(t["downloadDir"]),
|
||||
NumSeeds: int(toInt64(t["peersSendingToUs"])),
|
||||
NumLeechs: int(toInt64(t["peersGettingFromUs"])),
|
||||
AddedOn: time.Unix(int64(toFloat64(t["addedDate"])), 0),
|
||||
Tags: toJSONLabels(t["labels"]),
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// transmissionStateStr 将 Transmission 状态码转为可读字符串。
|
||||
func transmissionStateStr(status int) string {
|
||||
switch status {
|
||||
case 0:
|
||||
return "stopped"
|
||||
case 1:
|
||||
return "check_pending"
|
||||
case 2:
|
||||
return "checking"
|
||||
case 3:
|
||||
return "download_pending"
|
||||
case 4:
|
||||
return "downloading"
|
||||
case 5:
|
||||
return "seed_pending"
|
||||
case 6:
|
||||
return "seeding"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// toInt64 安全地将 interface{} 转为 int64。
|
||||
func toInt64(v interface{}) int64 {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return int64(val)
|
||||
case int:
|
||||
return int64(val)
|
||||
case int64:
|
||||
return val
|
||||
case json.Number:
|
||||
n, _ := val.Int64()
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(val, 10, 64)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// toFloat64 安全地将 interface{} 转为 float64。
|
||||
func toFloat64(v interface{}) float64 {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val
|
||||
case int:
|
||||
return float64(val)
|
||||
case int64:
|
||||
return float64(val)
|
||||
case json.Number:
|
||||
n, _ := val.Float64()
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconv.ParseFloat(val, 64)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// strVal 安全地提取字符串。
|
||||
func strVal(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
||||
// toJSONLabels 将 Transmission labels 转为逗号分隔字符串。
|
||||
func toJSONLabels(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
labels := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok {
|
||||
labels = append(labels, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(labels, ",")
|
||||
}
|
||||
BIN
Binary file not shown.
+10
-8
@@ -51,9 +51,6 @@ const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m
|
||||
const FileManagerPage = lazy(() =>
|
||||
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
|
||||
)
|
||||
const APIConfigsPage = lazy(() =>
|
||||
import('./pages/APIConfigsPage').then((m) => ({ default: m.APIConfigsPage })),
|
||||
)
|
||||
const StoragePage = lazy(() =>
|
||||
import('./pages/StoragePage').then((m) => ({ default: m.StoragePage })),
|
||||
)
|
||||
@@ -69,6 +66,7 @@ const WatchHistoryPage = lazy(() =>
|
||||
const PosterWallPage = lazy(() =>
|
||||
import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })),
|
||||
)
|
||||
const SitesPage = lazy(() => import('./pages/SitesPage').then((m) => ({ default: m.SitesPage })))
|
||||
|
||||
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
||||
|
||||
@@ -100,6 +98,14 @@ export default function App() {
|
||||
<Route path="dlna" element={<DlnaPage />} />
|
||||
<Route path="history" element={<WatchHistoryPage />} />
|
||||
<Route path="poster-wall" element={<PosterWallPage />} />
|
||||
<Route
|
||||
path="sites"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<SitesPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
@@ -134,11 +140,7 @@ export default function App() {
|
||||
/>
|
||||
<Route
|
||||
path="api-configs"
|
||||
element={
|
||||
<RequireAdmin>
|
||||
<APIConfigsPage />
|
||||
</RequireAdmin>
|
||||
}
|
||||
element={<Navigate to="/admin" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="tasks"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// API 配置 API 模块
|
||||
import { api } from './client'
|
||||
import type { ApiConfig, ApiProvider } from '../types'
|
||||
|
||||
// 获取所有 API 配置
|
||||
export async function listApiConfigs(): Promise<ApiConfig[]> {
|
||||
const resp = await api.get<ApiConfig[]>('/api-config')
|
||||
return resp.data as unknown as ApiConfig[]
|
||||
}
|
||||
|
||||
// 获取提供者列表
|
||||
export async function getProviders(): Promise<ApiProvider[]> {
|
||||
const resp = await api.get<ApiProvider[]>('/api-config/providers/list')
|
||||
return resp.data as unknown as ApiProvider[]
|
||||
}
|
||||
|
||||
// 获取指定提供者的配置
|
||||
export async function getApiConfig(provider: string): Promise<ApiConfig> {
|
||||
const resp = await api.get<ApiConfig>(`/api-config/${provider}`)
|
||||
return resp.data as unknown as ApiConfig
|
||||
}
|
||||
|
||||
// 获取生效的配置
|
||||
export async function getEffectiveConfig(provider: string): Promise<ApiConfig> {
|
||||
const resp = await api.get<ApiConfig>(`/api-config/${provider}/effective`)
|
||||
return resp.data as unknown as ApiConfig
|
||||
}
|
||||
|
||||
// 更新 API 配置
|
||||
export interface UpdateApiConfigRequest {
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
extra?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export async function updateApiConfig(
|
||||
provider: string,
|
||||
data: UpdateApiConfigRequest
|
||||
): Promise<ApiConfig> {
|
||||
const resp = await api.post<ApiConfig>(`/api-config/${provider}`, data)
|
||||
return resp.data as unknown as ApiConfig
|
||||
}
|
||||
|
||||
// 删除 API 配置
|
||||
export async function deleteApiConfig(provider: string): Promise<void> {
|
||||
await api.delete(`/api-config/${provider}`)
|
||||
}
|
||||
|
||||
// 测试 API 连接
|
||||
export interface TestApiConfigResponse {
|
||||
result: 'success' | 'error' | 'invalid' | 'unknown'
|
||||
}
|
||||
|
||||
export async function testApiConfig(provider: string): Promise<TestApiConfigResponse> {
|
||||
const resp = await api.post<TestApiConfigResponse>(`/api-config/${provider}/test`)
|
||||
return resp.data as unknown as TestApiConfigResponse
|
||||
}
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import { api } from './client'
|
||||
import type { User } from '../types'
|
||||
import type { User, TokenPair } from '../types'
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: User
|
||||
tokens: TokenPair
|
||||
}
|
||||
|
||||
export const authAPI = {
|
||||
@@ -11,7 +11,7 @@ export const authAPI = {
|
||||
api.post<LoginResponse>('/auth/login', { username, password }).then((r) => r.data),
|
||||
|
||||
register: (username: string, password: string) =>
|
||||
api.post<User>('/auth/register', { username, password }).then((r) => r.data),
|
||||
api.post<LoginResponse>('/auth/register', { username, password }).then((r) => r.data),
|
||||
|
||||
me: () => api.get<User>('/me').then((r) => r.data),
|
||||
|
||||
|
||||
+68
-3
@@ -1,4 +1,4 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
@@ -9,6 +9,22 @@ export const api = axios.create({
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// Flag to prevent multiple simultaneous refresh attempts
|
||||
let isRefreshing = false
|
||||
let refreshSubscribers: Array<(token: string) => void> = []
|
||||
|
||||
// Subscribe to token refresh
|
||||
function subscribeTokenRefresh(callback: (token: string) => void) {
|
||||
refreshSubscribers.push(callback)
|
||||
}
|
||||
|
||||
// Notify all subscribers about new token
|
||||
function onTokenRefreshed(newToken: string) {
|
||||
refreshSubscribers.forEach(callback => callback(newToken))
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token
|
||||
if (token) {
|
||||
@@ -18,15 +34,54 @@ api.interceptors.request.use((config) => {
|
||||
return config
|
||||
})
|
||||
|
||||
// Handle 401 errors with token refresh
|
||||
api.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(err: AxiosError) => {
|
||||
if (err.response?.status === 401) {
|
||||
async (err: AxiosError) => {
|
||||
const originalRequest = err.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
// If 401 and not already retried
|
||||
if (err.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||
if (isRefreshing) {
|
||||
// Wait for token refresh to complete
|
||||
return new Promise((resolve) => {
|
||||
subscribeTokenRefresh((token: string) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
resolve(api(originalRequest))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const refreshed = await useAuthStore.getState().tokenRefresh()
|
||||
if (refreshed) {
|
||||
const newToken = useAuthStore.getState().token
|
||||
if (newToken && originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
onTokenRefreshed(newToken || '')
|
||||
isRefreshing = false
|
||||
return api(originalRequest)
|
||||
}
|
||||
} catch (refreshError) {
|
||||
isRefreshing = false
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
// Refresh failed, logout
|
||||
useAuthStore.getState().logout()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
// For other errors, just reject
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
@@ -55,3 +110,13 @@ export function imageURL(remote?: string): string {
|
||||
if (remote.startsWith('/api/img')) return remote
|
||||
return `/api/img?url=${encodeURIComponent(remote)}&${tokenQuery()}`
|
||||
}
|
||||
|
||||
// getToken returns the current auth token
|
||||
export function getToken(): string | null {
|
||||
return useAuthStore.getState().token
|
||||
}
|
||||
|
||||
// getRefreshToken returns the current refresh token
|
||||
export function getRefreshToken(): string | null {
|
||||
return useAuthStore.getState().refreshToken
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { api } from './client'
|
||||
import type { DownloadClient } from '../types'
|
||||
|
||||
export interface DownloadClientCreateParams {
|
||||
name: string
|
||||
type: 'qbittorrent' | 'transmission' | 'aria2'
|
||||
host: string
|
||||
username?: string
|
||||
password?: string
|
||||
is_default?: boolean
|
||||
extra?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface DownloadClientUpdateParams {
|
||||
name?: string
|
||||
type?: 'qbittorrent' | 'transmission' | 'aria2'
|
||||
host?: string
|
||||
username?: string
|
||||
password?: string
|
||||
is_default?: boolean
|
||||
enabled?: boolean
|
||||
extra?: Record<string, string>
|
||||
}
|
||||
|
||||
export const downloadClientAPI = {
|
||||
list: () =>
|
||||
api
|
||||
.get<{ code: number; data: DownloadClient[] }>('/download-clients')
|
||||
.then((r) => r.data.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api
|
||||
.get<{ code: number; data: DownloadClient }>(`/download-clients/${id}`)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
create: (params: DownloadClientCreateParams) =>
|
||||
api
|
||||
.post<{ code: number; data: DownloadClient }>('/download-clients', params)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
update: (id: string, params: DownloadClientUpdateParams) =>
|
||||
api
|
||||
.put<{ code: number; data: DownloadClient }>(`/download-clients/${id}`, params)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete(`/download-clients/${id}`).then((r) => r.data),
|
||||
|
||||
test: (id: string) =>
|
||||
api.post(`/download-clients/${id}/test`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { api } from './client'
|
||||
import type { NotifyChannel, NotifyProviderInfo } from '../types'
|
||||
|
||||
export interface NotifyChannelCreateParams {
|
||||
name: string
|
||||
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
||||
enabled?: boolean
|
||||
config: Record<string, string>
|
||||
events?: string[]
|
||||
}
|
||||
|
||||
export interface NotifyChannelUpdateParams {
|
||||
name?: string
|
||||
enabled?: boolean
|
||||
config?: Record<string, string>
|
||||
events?: string[]
|
||||
}
|
||||
|
||||
export const notifyAPI = {
|
||||
list: () =>
|
||||
api
|
||||
.get<{ code: number; data: NotifyChannel[] }>('/notify-channels')
|
||||
.then((r) => r.data.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api
|
||||
.get<{ code: number; data: NotifyChannel }>(`/notify-channels/${id}`)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
getTypes: () =>
|
||||
api
|
||||
.get<{ code: number; data: NotifyProviderInfo[] }>('/notify-channels/types')
|
||||
.then((r) => r.data.data),
|
||||
|
||||
create: (params: NotifyChannelCreateParams) =>
|
||||
api
|
||||
.post<{ code: number; data: NotifyChannel }>('/notify-channels', params)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
update: (id: string, params: NotifyChannelUpdateParams) =>
|
||||
api
|
||||
.put<{ code: number; data: NotifyChannel }>(`/notify-channels/${id}`, params)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
delete: (id: string) =>
|
||||
api.delete(`/notify-channels/${id}`).then((r) => r.data),
|
||||
|
||||
test: (id: string) =>
|
||||
api.post(`/notify-channels/${id}/test`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 权限 API 模块
|
||||
import { api } from './client'
|
||||
import type { UserPermission } from '../types'
|
||||
|
||||
// 获取用户权限
|
||||
export async function getUserPermissions(userId: string): Promise<UserPermission> {
|
||||
const resp = await api.get<UserPermission>(`/admin/users/${userId}/permissions`)
|
||||
return resp.data as unknown as UserPermission
|
||||
}
|
||||
|
||||
// 更新用户权限
|
||||
export async function updateUserPermissions(
|
||||
userId: string,
|
||||
permissions: Record<string, boolean>
|
||||
): Promise<void> {
|
||||
await api.put(`/admin/users/${userId}/permissions`, { permissions })
|
||||
}
|
||||
|
||||
// 重置用户权限为默认值
|
||||
export async function resetUserPermissions(userId: string): Promise<void> {
|
||||
await api.post(`/admin/users/${userId}/permissions/reset`)
|
||||
}
|
||||
|
||||
// 获取当前用户权限
|
||||
export async function getMyPermissions(): Promise<{
|
||||
permissions: Record<string, boolean>
|
||||
role: string
|
||||
tier: string
|
||||
is_super: boolean
|
||||
}> {
|
||||
const resp = await api.get('/auth/permissions')
|
||||
return resp.data as unknown as {
|
||||
permissions: Record<string, boolean>
|
||||
role: string
|
||||
tier: string
|
||||
is_super: boolean
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 令牌刷新 API 模块
|
||||
import { api } from './client'
|
||||
|
||||
// 刷新令牌请求/响应
|
||||
export interface RefreshTokenRequest {
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// 刷新访问令牌
|
||||
export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
|
||||
const resp = await api.post<RefreshTokenResponse>('/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return resp.data as unknown as RefreshTokenResponse
|
||||
}
|
||||
|
||||
// 登出
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post('/me/logout')
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { api } from './client'
|
||||
|
||||
// Site management API - matches Go backend /api/sites endpoints
|
||||
export const sitesAPI = {
|
||||
// List all sites
|
||||
list: () => api.get('/sites').then((r) => r.data),
|
||||
|
||||
// Get single site with decrypted fields
|
||||
get: (id: string | number) => api.get(`/sites/${id}`).then((r) => r.data),
|
||||
|
||||
// Create a new site
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post('/sites', data).then((r) => r.data),
|
||||
|
||||
// Update existing site
|
||||
update: (id: string | number, data: Record<string, unknown>) =>
|
||||
api.put(`/sites/${id}`, data).then((r) => r.data),
|
||||
|
||||
// Delete a site
|
||||
remove: (id: string | number) =>
|
||||
api.delete(`/sites/${id}`).then((r) => r.data),
|
||||
|
||||
// Test site connectivity
|
||||
test: (id: string | number) =>
|
||||
api.post(`/sites/${id}/test`).then((r) => r.data),
|
||||
|
||||
// Get supported site types
|
||||
types: () => api.get('/sites/types').then((r) => r.data),
|
||||
|
||||
// Get supported auth types
|
||||
authTypes: () => api.get('/sites/auth-types').then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Eye, KeyRound, Save, Trash2, X } from 'lucide-react'
|
||||
|
||||
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
|
||||
|
||||
// Compact inline-editable provider table for use inside AdminPage's "外部API" tab.
|
||||
export function APIConfigsPanel() {
|
||||
const [items, setItems] = useState<APIConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
|
||||
const refresh = () =>
|
||||
apiConfigsAPI
|
||||
.list()
|
||||
.then(setItems)
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="h-5 w-5 text-primary-400" />
|
||||
<div>
|
||||
<p className="font-display text-lg font-semibold text-white">外部 API 配置</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 密钥管理
|
||||
· AES-GCM 加密存储
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<p className="py-6 text-center text-sm text-slate-500">加载中…</p>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className="glass-panel overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-white/5 text-xs uppercase tracking-wider text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">服务</th>
|
||||
<th className="px-4 py-3">密钥</th>
|
||||
<th className="px-4 py-3">状态</th>
|
||||
<th className="px-4 py-3 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) =>
|
||||
editing === item.provider ? (
|
||||
<EditingRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null)
|
||||
refresh()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-t border-white/5 transition hover:bg-white/[0.02]"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-white">{item.provider}</p>
|
||||
{item.description && (
|
||||
<p className="text-xs text-slate-500">{item.description}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
{item.has_key ? (
|
||||
<span className="text-primary-400">{item.masked_key}</span>
|
||||
) : (
|
||||
<span className="text-slate-600">未配置</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.has_key ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-400/10 px-2 py-0.5 text-xs text-emerald-400">
|
||||
已配置
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-400/10 px-2 py-0.5 text-xs text-slate-500">
|
||||
未配置
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => setEditing(item.provider)}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-white/5 hover:text-white"
|
||||
title="编辑"
|
||||
>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
toast(
|
||||
`已配置 ${item.has_key ? '✓' : '✗'} 密钥 (在线测试请用对应功能页面)`,
|
||||
)
|
||||
}}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-white/5 hover:text-slate-200"
|
||||
title="查看状态"
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
{item.has_key && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`确定清除 ${item.provider} 的 API Key?`)) return
|
||||
await apiConfigsAPI.remove(item.provider)
|
||||
toast.success('已清除')
|
||||
refresh()
|
||||
}}
|
||||
className="rounded p-1.5 text-slate-400 transition hover:bg-red-400/10 hover:text-red-400"
|
||||
title="清除密钥"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditingRow({
|
||||
item,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: {
|
||||
item: APIConfig
|
||||
onCancel: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [apiKey, setAPIKey] = useState('')
|
||||
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
|
||||
const [enabled, setEnabled] = useState(item.enabled)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
|
||||
if (apiKey.trim()) patch.api_key = apiKey.trim()
|
||||
await apiConfigsAPI.update(item.provider, patch)
|
||||
toast.success(`${item.provider} 已保存`)
|
||||
onSaved()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="border-t border-white/5 bg-primary-400/5">
|
||||
<td colSpan={4} className="px-4 py-3">
|
||||
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
|
||||
<span className="text-sm font-medium text-white">{item.provider}</span>
|
||||
<label className="flex-1 text-xs text-slate-400">
|
||||
API Key
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
type="password"
|
||||
placeholder={item.has_key ? '•••••••••••• (留空保留原值)' : '输入密钥'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setAPIKey(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-1 text-xs text-slate-400">
|
||||
Base URL
|
||||
<input
|
||||
className="input-base mt-1"
|
||||
placeholder="https://api.themoviedb.org/3"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<button type="submit" disabled={saving} className="neon-button !px-3 !py-1.5 !text-xs">
|
||||
<Save size={12} /> 保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded border border-slate-400/30 px-2 py-1.5 text-xs text-slate-400 hover:text-white"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Plug, Trash2, Zap, Settings, Loader2 } from 'lucide-react'
|
||||
|
||||
import { downloadClientAPI, type DownloadClientCreateParams } from '../api/downloadClient'
|
||||
import type { DownloadClient } from '../types'
|
||||
|
||||
const CLIENT_TYPES = [
|
||||
{ value: 'qbittorrent', label: 'qBittorrent' },
|
||||
{ value: 'transmission', label: 'Transmission' },
|
||||
{ value: 'aria2', label: 'Aria2' },
|
||||
] as const
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
qbittorrent: 'qBittorrent',
|
||||
transmission: 'Transmission',
|
||||
aria2: 'Aria2',
|
||||
}
|
||||
|
||||
export function DownloadClientCard() {
|
||||
const [clients, setClients] = useState<DownloadClient[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [testing, setTesting] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<DownloadClientCreateParams>({
|
||||
name: '',
|
||||
type: 'qbittorrent',
|
||||
host: '',
|
||||
username: '',
|
||||
password: '',
|
||||
is_default: false,
|
||||
})
|
||||
|
||||
const refresh = () => downloadClientAPI.list().then(setClients)
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await downloadClientAPI.create(form)
|
||||
toast.success('下载客户端已创建')
|
||||
setForm({ name: '', type: 'qbittorrent', host: '', username: '', password: '', is_default: false })
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
} catch {
|
||||
toast.error('创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTesting(id)
|
||||
try {
|
||||
await downloadClientAPI.test(id)
|
||||
toast.success('连接测试成功')
|
||||
} catch {
|
||||
toast.error('连接测试失败')
|
||||
} finally {
|
||||
setTesting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`确定删除「${name}」?`)) return
|
||||
await downloadClientAPI.delete(id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleToggleDefault = async (client: DownloadClient) => {
|
||||
await downloadClientAPI.update(client.id, { is_default: true })
|
||||
toast.success(`已设「${client.name}」为默认客户端`)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (client: DownloadClient) => {
|
||||
await downloadClientAPI.update(client.id, { enabled: !client.enabled })
|
||||
toast.success(client.enabled ? '已禁用' : '已启用')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">下载客户端</h2>
|
||||
<button
|
||||
className="neon-button flex items-center gap-1 text-sm"
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
>
|
||||
<Plug size={14} />
|
||||
添加客户端
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="glass-panel space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className="input-base"
|
||||
value={form.type}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, type: e.target.value as DownloadClientCreateParams['type'] })
|
||||
}
|
||||
>
|
||||
{CLIENT_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="地址 (如 http://127.0.0.1:8080)"
|
||||
value={form.host}
|
||||
onChange={(e) => setForm({ ...form, host: e.target.value })}
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
className="input-base"
|
||||
placeholder="用户名"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="input-base"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_default}
|
||||
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
|
||||
className="rounded border-slate-600"
|
||||
/>
|
||||
设为默认客户端
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="neon-button">
|
||||
创建
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-white/20 px-3 py-1.5 text-sm text-slate-300 hover:bg-white/5"
|
||||
onClick={() => setShowForm(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{clients.map((client) => (
|
||||
<div
|
||||
key={client.id}
|
||||
className={`glass-panel flex items-center justify-between ${
|
||||
!client.enabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`rounded-lg p-2 ${
|
||||
client.type === 'qbittorrent'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: client.type === 'transmission'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: 'bg-orange-500/20 text-orange-400'
|
||||
}`}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">{client.name}</span>
|
||||
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs text-slate-400">
|
||||
{TYPE_LABELS[client.type]}
|
||||
</span>
|
||||
{client.is_default && (
|
||||
<span className="rounded bg-primary-400/20 px-1.5 py-0.5 text-xs text-primary-400">
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
{!client.enabled && (
|
||||
<span className="rounded bg-red-400/20 px-1.5 py-0.5 text-xs text-red-400">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{client.host}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!client.is_default && (
|
||||
<button
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
onClick={() => handleToggleDefault(client)}
|
||||
title="设为默认"
|
||||
>
|
||||
<Zap size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rounded border border-yellow-400/40 px-2 py-1 text-xs text-yellow-400 hover:bg-yellow-400/10"
|
||||
onClick={() => handleToggleEnabled(client)}
|
||||
title={client.enabled ? '禁用' : '启用'}
|
||||
>
|
||||
{client.enabled ? '暂停' : '启用'}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
onClick={() => handleTest(client.id)}
|
||||
disabled={testing === client.id}
|
||||
>
|
||||
{testing === client.id ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
'测试'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
onClick={() => handleDelete(client.id, client.name)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{clients.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-slate-500">
|
||||
暂无下载客户端,点击上方按钮添加
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
Copy,
|
||||
Film,
|
||||
FolderTree,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Home,
|
||||
GalleryHorizontalEnd,
|
||||
KeyRound,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
LogOut,
|
||||
@@ -94,18 +94,18 @@ export function Layout() {
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<>
|
||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
||||
管理
|
||||
</div>
|
||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
|
||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
|
||||
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
|
||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
|
||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
|
||||
<SidebarLink to="/api-configs" icon={<KeyRound size={18} />} label="API 配置" />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
||||
管理
|
||||
</div>
|
||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
|
||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
||||
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
|
||||
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
|
||||
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
|
||||
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
|
||||
<SidebarLink to="/sites" icon={<Globe size={18} />} label="站点管理" />
|
||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Bell, Trash2, Loader2 } from 'lucide-react'
|
||||
|
||||
import { notifyAPI, type NotifyChannelCreateParams } from '../api/notify'
|
||||
import type { NotifyChannel } from '../types'
|
||||
|
||||
const CHANNEL_TYPES = [
|
||||
{ value: 'telegram', label: 'Telegram' },
|
||||
{ value: 'wechat', label: 'Server酱' },
|
||||
{ value: 'bark', label: 'Bark' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
] as const
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
telegram: 'Telegram',
|
||||
wechat: 'Server酱',
|
||||
bark: 'Bark',
|
||||
webhook: 'Webhook',
|
||||
email: 'Email',
|
||||
}
|
||||
|
||||
const EVENT_OPTIONS = [
|
||||
{ value: 'subscription_hit', label: '订阅命中' },
|
||||
{ value: 'download_complete', label: '下载完成' },
|
||||
{ value: 'scrape_failed', label: '刮削失败' },
|
||||
{ value: 'system_alert', label: '系统告警' },
|
||||
]
|
||||
|
||||
const CONFIG_FIELDS: Record<string, { key: string; label: string; type?: string; placeholder: string }[]> = {
|
||||
telegram: [
|
||||
{ key: 'bot_token', label: 'Bot Token', placeholder: '123456:ABC-DEF...' },
|
||||
{ key: 'chat_id', label: 'Chat ID', placeholder: '你的 Chat ID' },
|
||||
{ key: 'parse_mode', label: 'Parse Mode', placeholder: 'HTML (可选)' },
|
||||
],
|
||||
wechat: [{ key: 'sendkey', label: 'SendKey', placeholder: 'SCT...' }],
|
||||
bark: [
|
||||
{ key: 'server_url', label: '服务器地址', placeholder: 'https://api.day.app (可选)' },
|
||||
{ key: 'device_key', label: 'Device Key', placeholder: '你的 Bark Key' },
|
||||
],
|
||||
webhook: [
|
||||
{ key: 'url', label: 'Webhook URL', placeholder: 'https://...' },
|
||||
{ key: 'method', label: 'HTTP 方法', placeholder: 'POST (可选)' },
|
||||
{ key: 'headers_json', label: 'Headers JSON', placeholder: '{"Authorization":"Bearer ..."} (可选)' },
|
||||
{ key: 'body_template', label: 'Body 模板', placeholder: '{{title}}: {{message}} (可选)' },
|
||||
],
|
||||
email: [
|
||||
{ key: 'smtp_host', label: 'SMTP 地址', placeholder: 'smtp.gmail.com' },
|
||||
{ key: 'smtp_port', label: 'SMTP 端口', placeholder: '465' },
|
||||
{ key: 'username', label: '用户名', placeholder: '' },
|
||||
{ key: 'password', label: '密码', placeholder: '', type: 'password' },
|
||||
{ key: 'from', label: '发件人', placeholder: 'noreply@example.com' },
|
||||
{ key: 'to', label: '收件人', placeholder: 'user@example.com (多个用逗号分隔)' },
|
||||
{ key: 'tls', label: 'TLS', placeholder: 'true/false' },
|
||||
],
|
||||
}
|
||||
|
||||
export function NotifyChannelCard() {
|
||||
const [channels, setChannels] = useState<NotifyChannel[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [testing, setTesting] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<NotifyChannelCreateParams>({
|
||||
name: '',
|
||||
type: 'telegram',
|
||||
enabled: true,
|
||||
config: {},
|
||||
events: [],
|
||||
})
|
||||
|
||||
const refresh = () => notifyAPI.list().then(setChannels)
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const handleTypeChange = (type: string) => {
|
||||
setForm({
|
||||
...form,
|
||||
type: type as NotifyChannelCreateParams['type'],
|
||||
config: {},
|
||||
})
|
||||
}
|
||||
|
||||
const handleConfigChange = (key: string, value: string) => {
|
||||
setForm({ ...form, config: { ...form.config, [key]: value } })
|
||||
}
|
||||
|
||||
const handleEventToggle = (event: string) => {
|
||||
const events = form.events || []
|
||||
const idx = events.indexOf(event)
|
||||
if (idx >= 0) {
|
||||
events.splice(idx, 1)
|
||||
} else {
|
||||
events.push(event)
|
||||
}
|
||||
setForm({ ...form, events: [...events] })
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await notifyAPI.create(form)
|
||||
toast.success('通知渠道已创建')
|
||||
setForm({ name: '', type: 'telegram', enabled: true, config: {}, events: [] })
|
||||
setShowForm(false)
|
||||
await refresh()
|
||||
} catch {
|
||||
toast.error('创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTesting(id)
|
||||
try {
|
||||
await notifyAPI.test(id)
|
||||
toast.success('测试通知已发送')
|
||||
} catch {
|
||||
toast.error('测试通知发送失败')
|
||||
} finally {
|
||||
setTesting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`确定删除「${name}」?`)) return
|
||||
await notifyAPI.delete(id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (channel: NotifyChannel) => {
|
||||
await notifyAPI.update(channel.id, { enabled: !channel.enabled })
|
||||
toast.success(channel.enabled ? '已禁用' : '已启用')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const configFields = CONFIG_FIELDS[form.type] || []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">通知渠道</h2>
|
||||
<button
|
||||
className="neon-button flex items-center gap-1 text-sm"
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
>
|
||||
<Bell size={14} />
|
||||
添加渠道
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="glass-panel space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
placeholder="名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
<select className="input-base" value={form.type} onChange={(e) => handleTypeChange(e.target.value)}>
|
||||
{CHANNEL_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{configFields.map((field) => (
|
||||
<input
|
||||
key={field.key}
|
||||
required={field.key !== 'parse_mode' && field.key !== 'method' && field.key !== 'headers_json' && field.key !== 'body_template' && field.key !== 'server_url' && field.key !== 'tls'}
|
||||
className="input-base"
|
||||
type={field.type || 'text'}
|
||||
placeholder={field.label + (field.placeholder ? ` (${field.placeholder})` : '')}
|
||||
value={form.config[field.key] || ''}
|
||||
onChange={(e) => handleConfigChange(field.key, e.target.value)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-500">订阅事件</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EVENT_OPTIONS.map((event) => (
|
||||
<label
|
||||
key={event.value}
|
||||
className="flex cursor-pointer items-center gap-1 rounded border border-white/10 px-2 py-1 text-xs text-slate-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(form.events || []).includes(event.value)}
|
||||
onChange={() => handleEventToggle(event.value)}
|
||||
className="rounded border-slate-600"
|
||||
/>
|
||||
{event.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="neon-button">
|
||||
创建
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-white/20 px-3 py-1.5 text-sm text-slate-300 hover:bg-white/5"
|
||||
onClick={() => setShowForm(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{channels.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
className={`glass-panel flex items-center justify-between ${
|
||||
!channel.enabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-green-500/20 p-2 text-green-400">
|
||||
<Bell size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">{channel.name}</span>
|
||||
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs text-slate-400">
|
||||
{TYPE_LABELS[channel.type]}
|
||||
</span>
|
||||
{!channel.enabled && (
|
||||
<span className="rounded bg-red-400/20 px-1.5 py-0.5 text-xs text-red-400">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
事件: {channel.events || '无'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="rounded border border-yellow-400/40 px-2 py-1 text-xs text-yellow-400 hover:bg-yellow-400/10"
|
||||
onClick={() => handleToggleEnabled(channel)}
|
||||
>
|
||||
{channel.enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||
onClick={() => handleTest(channel.id)}
|
||||
disabled={testing === channel.id}
|
||||
>
|
||||
{testing === channel.id ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
'测试'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
onClick={() => handleDelete(channel.id, channel.name)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{channels.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-slate-500">
|
||||
暂无通知渠道,点击上方按钮添加
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { usePermissionStore } from '../stores/permissions'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
interface PermissionGuardProps {
|
||||
permission: string
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
requireSuperUser?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* PermissionGuard 组件用于根据用户权限控制内容显示。
|
||||
*
|
||||
* @param permission - 需要的权限键
|
||||
* @param children - 有权限时显示的内容
|
||||
* @param fallback - 无权限时显示的内容(可选,默认不显示)
|
||||
* @param requireSuperUser - 是否要求超级用户(admin/plus)绕过权限检查
|
||||
*/
|
||||
export function PermissionGuard({
|
||||
permission,
|
||||
children,
|
||||
fallback = null,
|
||||
requireSuperUser = false,
|
||||
}: PermissionGuardProps) {
|
||||
const { hasPermission, isSuper, permissions, isLoading } = usePermissionStore()
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
|
||||
// 超级用户(admin 或 plus)默认有所有权限
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// 如果 requireSuperUser 为 true 且用户不是超级用户,则不显示
|
||||
if (requireSuperUser && !isSuper) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 加载中时显示 fallback
|
||||
if (isLoading && Object.keys(permissions).length === 0) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 检查具体权限
|
||||
if (hasPermission(permission)) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 权限检查工具函数
|
||||
export function checkPermission(
|
||||
permission: string,
|
||||
isSuper: boolean,
|
||||
tier: string,
|
||||
role: string,
|
||||
permissions: Record<string, boolean>
|
||||
): boolean {
|
||||
// 超级用户有所有权限
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return true
|
||||
}
|
||||
return permissions[permission] === true
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { usePermissionStore } from '../stores/permissions'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
/**
|
||||
* usePermission hook - 检查用户是否拥有特定权限
|
||||
*
|
||||
* @param key - 权限键名
|
||||
* @param options - 配置选项
|
||||
* @param options.autoFetch - 是否在权限未加载时自动获取(默认 true)
|
||||
* @returns boolean - 用户是否拥有该权限
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const canEdit = usePermission('can_edit_media')
|
||||
*
|
||||
* if (canEdit) {
|
||||
* return <EditButton />
|
||||
* }
|
||||
* return null
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function usePermission(
|
||||
key: string,
|
||||
options: { autoFetch?: boolean } = {}
|
||||
): boolean {
|
||||
const { autoFetch = true } = options
|
||||
const { hasPermission, isSuper, permissions, isLoading, fetchPermissions } = usePermissionStore()
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||
|
||||
// 超级用户有所有权限
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return true
|
||||
}
|
||||
|
||||
// 权限未加载且未认证时,返回 false
|
||||
if (!isAuthenticated) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 权限未加载时自动获取
|
||||
useEffect(() => {
|
||||
if (autoFetch && Object.keys(permissions).length === 0 && !isLoading) {
|
||||
fetchPermissions()
|
||||
}
|
||||
}, [autoFetch, permissions, isLoading, fetchPermissions])
|
||||
|
||||
return hasPermission(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* usePermissions hook - 获取所有权限
|
||||
*
|
||||
* @returns 权限状态和检查函数
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { permissions, isSuper, check } = usePermissions()
|
||||
*
|
||||
* if (isSuper) {
|
||||
* return <AdminPanel />
|
||||
* }
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {check('can_view_dashboard') && <Dashboard />}
|
||||
* {check('can_play_media') && <Player />}
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const { permissions, isSuper, isLoading, fetchPermissions } = usePermissionStore()
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||
|
||||
const check = (key: string): boolean => {
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return true
|
||||
}
|
||||
return permissions[key] === true
|
||||
}
|
||||
|
||||
return {
|
||||
permissions,
|
||||
isSuper: isSuper || tier === 'plus' || role === 'admin',
|
||||
isLoading,
|
||||
check,
|
||||
refetch: fetchPermissions,
|
||||
isAuthenticated,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* usePermissionMany hook - 批量检查多个权限
|
||||
*
|
||||
* @param keys - 权限键数组
|
||||
* @returns 每个权限的布尔值映射
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const perms = usePermissionMany([
|
||||
* 'can_edit_media',
|
||||
* 'can_manage_users',
|
||||
* 'can_access_settings',
|
||||
* ])
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {perms['can_edit_media'] && <EditButton />}
|
||||
* {perms['can_manage_users'] && <UserManagement />}
|
||||
* </div>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function usePermissionMany(keys: string[]): Record<string, boolean> {
|
||||
const { check } = usePermissions()
|
||||
|
||||
return keys.reduce((acc, key) => {
|
||||
acc[key] = check(key)
|
||||
return acc
|
||||
}, {} as Record<string, boolean>)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { SSEEvent } from '../types'
|
||||
|
||||
type SSEEventHandler = (event: SSEEvent) => void
|
||||
|
||||
/**
|
||||
* useSSE hook - 管理 Server-Sent Events 连接
|
||||
*
|
||||
* @param onEvent - 事件处理函数
|
||||
* @param options - 配置选项
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { connect, disconnect } = useSSE((event) => {
|
||||
* if (event.type === 'scan') {
|
||||
* console.log('Scan progress:', event.payload)
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* useEffect(() => {
|
||||
* connect()
|
||||
* return () => disconnect()
|
||||
* }, [])
|
||||
*
|
||||
* return <div>SSE Demo</div>
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSSE(
|
||||
onEvent: SSEEventHandler,
|
||||
options: { autoConnect?: boolean } = {}
|
||||
) {
|
||||
const { autoConnect = true } = options
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isConnectedRef = useRef(false)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const maxReconnectAttempts = 5
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// 如果已有连接,先断开
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
|
||||
const token = useAuthStore.getState().token
|
||||
if (!token) {
|
||||
console.warn('Cannot connect to SSE: No auth token')
|
||||
return
|
||||
}
|
||||
|
||||
const url = `/api/events?token=${encodeURIComponent(token)}`
|
||||
const eventSource = new EventSource(url)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onopen = () => {
|
||||
isConnectedRef.current = true
|
||||
reconnectAttemptsRef.current = 0
|
||||
console.log('SSE connected')
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as SSEEvent
|
||||
onEvent(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE event:', err)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
isConnectedRef.current = false
|
||||
eventSource.close()
|
||||
|
||||
// 尝试重连
|
||||
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000)
|
||||
reconnectAttemptsRef.current++
|
||||
console.log(`SSE reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current})`)
|
||||
reconnectTimeoutRef.current = setTimeout(connect, delay)
|
||||
} else {
|
||||
console.error('SSE connection failed after max attempts')
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义事件类型
|
||||
eventSource.addEventListener('connected', (event) => {
|
||||
console.log('SSE handshake received:', event)
|
||||
})
|
||||
|
||||
}, [onEvent])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
}
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
isConnectedRef.current = false
|
||||
reconnectAttemptsRef.current = 0
|
||||
}, [])
|
||||
|
||||
const isConnected = useCallback(() => {
|
||||
return isConnectedRef.current
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoConnect) {
|
||||
connect()
|
||||
}
|
||||
return () => {
|
||||
disconnect()
|
||||
}
|
||||
}, [autoConnect, connect, disconnect])
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
isConnected,
|
||||
}
|
||||
}
|
||||
+90
-13
@@ -4,39 +4,55 @@ import { Trash2 } from 'lucide-react'
|
||||
|
||||
import { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { schedulerAPI, type JobStatus } from '../api/scheduler'
|
||||
import type { Library, User } from '../types'
|
||||
import { DownloadClientCard } from '../components/DownloadClientCard'
|
||||
import { NotifyChannelCard } from '../components/NotifyChannelCard'
|
||||
import { APIConfigsPanel } from '../components/APIConfigsPanel'
|
||||
import { SitesPage } from './SitesPage'
|
||||
|
||||
// Single-page admin console. We split the surface into three panels:
|
||||
// • Libraries — create / delete / scan
|
||||
// • Users — list / delete (cannot delete self)
|
||||
// • Settings — generic key/value store viewer
|
||||
//
|
||||
// Each panel manages its own loading / refresh state.
|
||||
export function AdminPage() {
|
||||
const [tab, setTab] = useState<'library' | 'users' | 'settings'>('library')
|
||||
const [tab, setTab] = useState<
|
||||
'sites' | 'library' | 'users' | 'settings' | 'api' | 'downloads' | 'notify' | 'scheduler'
|
||||
>('sites')
|
||||
const tabs = [
|
||||
{ key: 'sites' as const, label: '站点管理' },
|
||||
{ key: 'library' as const, label: '媒体库' },
|
||||
{ key: 'users' as const, label: '用户' },
|
||||
{ key: 'api' as const, label: '外部API' },
|
||||
{ key: 'settings' as const, label: '系统设置' },
|
||||
{ key: 'downloads' as const, label: '下载客户端' },
|
||||
{ key: 'notify' as const, label: '通知渠道' },
|
||||
{ key: 'scheduler' as const, label: '定时任务' },
|
||||
]
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">管理后台</h1>
|
||||
<div className="flex gap-2 border-b border-white/10">
|
||||
{(['library', 'users', 'settings'] as const).map((k) => (
|
||||
<div className="flex flex-wrap gap-2 border-b border-white/10">
|
||||
{tabs.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setTab(k)}
|
||||
key={k.key}
|
||||
onClick={() => setTab(k.key)}
|
||||
className={
|
||||
'border-b-2 px-4 py-2 text-sm transition ' +
|
||||
(tab === k
|
||||
(tab === k.key
|
||||
? 'border-primary-400 text-primary-400'
|
||||
: 'border-transparent text-slate-400 hover:text-white')
|
||||
}
|
||||
>
|
||||
{k === 'library' ? '媒体库' : k === 'users' ? '用户' : '设置'}
|
||||
{k.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'sites' && <SitesPage />}
|
||||
{tab === 'library' && <LibraryPanel />}
|
||||
{tab === 'users' && <UsersPanel />}
|
||||
{tab === 'api' && <APIConfigsPanel />}
|
||||
{tab === 'settings' && <SettingsPanel />}
|
||||
{tab === 'downloads' && <DownloadClientCard />}
|
||||
{tab === 'notify' && <NotifyChannelCard />}
|
||||
{tab === 'scheduler' && <SchedulerPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -251,3 +267,64 @@ function SettingsPanel() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SchedulerPanel() {
|
||||
const [tasks, setTasks] = useState<JobStatus[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true)
|
||||
schedulerAPI.status().then(setTasks).finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="glass-panel">
|
||||
{loading && <p className="py-4 text-center text-slate-400">加载中...</p>}
|
||||
{!loading && tasks.length === 0 && (
|
||||
<p className="py-4 text-center text-slate-400">暂无定时任务</p>
|
||||
)}
|
||||
{!loading && tasks.length > 0 && (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2">名称</th>
|
||||
<th>间隔</th>
|
||||
<th>上次运行</th>
|
||||
<th className="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((t) => (
|
||||
<tr key={t.name} className="border-t border-white/5">
|
||||
<td className="py-2 text-white">{t.name}</td>
|
||||
<td className="text-slate-300">{t.interval}</td>
|
||||
<td className="text-slate-400">
|
||||
{t.last_run ? new Date(t.last_run).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
className="rounded border border-amber-400/40 px-2 py-1 text-xs text-amber-400 hover:bg-amber-400/10"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await schedulerAPI.run(t.name)
|
||||
toast.success('任务已触发')
|
||||
refresh()
|
||||
} catch {
|
||||
toast.error('触发失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
手动运行
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
@@ -7,26 +8,76 @@ import type { Media } from '../types'
|
||||
export function FavouritesPage() {
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
playbackAPI
|
||||
.listFavourites()
|
||||
.then(setItems)
|
||||
.then((data) => setItems(data ?? []))
|
||||
.catch((err) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'加载收藏失败'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const isEmpty = !loading && !error && items.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-white">我的收藏</h1>
|
||||
{loading && <p className="text-slate-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="text-slate-400">还没有任何收藏,点击媒体详情页的「收藏」按钮添加。</p>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-slate-400">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary-400 border-t-transparent" />
|
||||
加载中…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="glass-panel !border-red-400/30 p-4 text-sm text-red-400">
|
||||
{error}
|
||||
<button
|
||||
className="ml-3 underline hover:text-red-300"
|
||||
onClick={() => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
playbackAPI
|
||||
.listFavourites()
|
||||
.then((data) => setItems(data ?? []))
|
||||
.catch((err2) => {
|
||||
const msg =
|
||||
(err2 as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'加载收藏失败'
|
||||
setError(msg)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<div className="glass-panel flex flex-col items-center gap-3 p-10 text-center">
|
||||
<p className="text-lg text-slate-300">还没有任何收藏</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
点击媒体详情页的「收藏」按钮添加喜欢的内容
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ export function LoginPage() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await authAPI.login(username, password)
|
||||
setSession(data.token, data.user)
|
||||
toast.success(`欢迎回来,${data.user.username}`)
|
||||
setSession(data.tokens.access_token, data.tokens.refresh_token, data.user)
|
||||
toast.success(`欢迎回来, ${data.user.username}`)
|
||||
navigate('/')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react'
|
||||
import { ChangeEvent, FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
@@ -7,16 +7,15 @@ import { mediaAPI } from '../api/library'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import type { Media } from '../types'
|
||||
|
||||
// SearchPage runs a fast LIKE search by default, with an optional AI
|
||||
// "smart search" toggle that calls /api/ai/search to produce a structured
|
||||
// intent before executing the library lookup.
|
||||
export function SearchPage() {
|
||||
const [q, setQ] = useState('')
|
||||
const [items, setItems] = useState<Media[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [aiOn, setAiOn] = useState(false)
|
||||
const [aiAvailable, setAiAvailable] = useState(false)
|
||||
const [intent, setIntent] = useState<SearchIntent | null>(null)
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
aiAPI
|
||||
@@ -25,37 +24,63 @@ export function SearchPage() {
|
||||
.catch(() => setAiAvailable(false))
|
||||
}, [])
|
||||
|
||||
const doQuickSearch = useCallback((query: string) => {
|
||||
if (!query.trim()) {
|
||||
setItems([])
|
||||
setHasSearched(false)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setHasSearched(true)
|
||||
setError('')
|
||||
mediaAPI
|
||||
.search(query, 60)
|
||||
.then((d) => {
|
||||
setItems(d.items ?? [])
|
||||
setIntent(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'搜索失败'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// Fast LIKE search-as-you-type when AI mode is OFF.
|
||||
useEffect(() => {
|
||||
if (aiOn) return
|
||||
const t = setTimeout(() => {
|
||||
setLoading(true)
|
||||
mediaAPI
|
||||
.search(q, 60)
|
||||
.then((d) => {
|
||||
setItems(d.items)
|
||||
setIntent(null)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, 300)
|
||||
setLoading(true)
|
||||
const t = setTimeout(() => doQuickSearch(q), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [q, aiOn])
|
||||
}, [q, aiOn, doQuickSearch])
|
||||
|
||||
const onAISubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!q.trim()) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setHasSearched(true)
|
||||
try {
|
||||
const data = await aiAPI.smartSearch(q)
|
||||
setItems(data.items)
|
||||
setItems(data.items ?? [])
|
||||
setIntent(data.intent)
|
||||
} catch {
|
||||
toast.error('AI 搜索失败')
|
||||
} catch (err) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'AI 搜索失败'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showEmpty = !loading && !error && hasSearched && items.length === 0
|
||||
const showIdle = !loading && !error && !hasSearched
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
@@ -78,7 +103,7 @@ export function SearchPage() {
|
||||
<input
|
||||
autoFocus
|
||||
className="input-base"
|
||||
placeholder='例如:“2010 年后的科幻电影” / “最近的动漫”'
|
||||
placeholder='例如:"2010 年后的科幻电影" / "最近的动漫"'
|
||||
value={q}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
|
||||
/>
|
||||
@@ -103,12 +128,40 @@ export function SearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-slate-500">搜索中…</p>}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-slate-400">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary-400 border-t-transparent" />
|
||||
搜索中…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="glass-panel !border-red-400/30 p-4 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
{showIdle && (
|
||||
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
|
||||
<p className="text-lg text-slate-300">输入关键词开始搜索</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
支持电影、电视剧、动漫等媒体内容的快速搜索
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmpty && (
|
||||
<div className="glass-panel flex flex-col items-center gap-2 p-10 text-center">
|
||||
<p className="text-lg text-slate-300">未找到匹配的媒体</p>
|
||||
<p className="text-sm text-slate-500">尝试其他关键词,或者添加媒体库后执行扫描</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((m) => (
|
||||
<MediaCard key={m.id} media={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { Globe, Plus, Trash2, Wifi, RefreshCw, X, Edit3, CheckCircle, XCircle, HelpCircle } from 'lucide-react'
|
||||
|
||||
import { sitesAPI } from '../api/sites'
|
||||
import type { Site } from '../types'
|
||||
|
||||
// ── 站点类型映射 ──
|
||||
const SITE_TYPE_LABELS: Record<string, string> = {
|
||||
nexusphp: 'NexusPHP',
|
||||
gazelle: 'Gazelle',
|
||||
unit3d: 'UNIT3D',
|
||||
mteam: 'M-Team',
|
||||
discuz: 'Discuz',
|
||||
custom_rss: '自定义 RSS',
|
||||
}
|
||||
|
||||
const SITE_TYPE_ABBR: Record<string, string> = {
|
||||
nexusphp: 'NP',
|
||||
gazelle: 'GZ',
|
||||
unit3d: 'U3',
|
||||
mteam: 'MT',
|
||||
discuz: 'DZ',
|
||||
custom_rss: 'RS',
|
||||
}
|
||||
|
||||
const SITE_TYPE_COLORS: Record<string, string> = {
|
||||
nexusphp: 'bg-blue-500/15 text-blue-400',
|
||||
gazelle: 'bg-purple-500/15 text-purple-400',
|
||||
unit3d: 'bg-orange-500/15 text-orange-400',
|
||||
mteam: 'bg-green-500/15 text-green-400',
|
||||
discuz: 'bg-yellow-500/15 text-yellow-400',
|
||||
custom_rss: 'bg-slate-500/15 text-slate-400',
|
||||
}
|
||||
|
||||
const AUTH_TYPE_LABELS: Record<string, string> = {
|
||||
cookie: 'Cookie',
|
||||
api_key: 'API Key',
|
||||
auth_header: 'Auth Header',
|
||||
}
|
||||
|
||||
// ── 默认表单 ──
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
url: '',
|
||||
type: 'nexusphp',
|
||||
auth_type: 'cookie',
|
||||
cookie: '',
|
||||
api_key: '',
|
||||
auth_header: '',
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
extra: '',
|
||||
})
|
||||
|
||||
export function SitesPage() {
|
||||
const [sites, setSites] = useState<Site[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState(defaultForm())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testingId, setTestingId] = useState<string | null>(null)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
||||
const loadSites = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await sitesAPI.list()
|
||||
setSites(Array.isArray(res.data) ? res.data : [])
|
||||
} catch {
|
||||
toast.error('加载站点列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSites()
|
||||
}, [])
|
||||
|
||||
// ── 弹窗操作 ──
|
||||
const openCreate = () => {
|
||||
setEditingId(null)
|
||||
setForm(defaultForm())
|
||||
setAdvancedOpen(false)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const openEdit = async (id: string) => {
|
||||
try {
|
||||
const res = await sitesAPI.get(id)
|
||||
const s = res.data as Site
|
||||
setEditingId(id)
|
||||
setForm({
|
||||
name: s.name || '',
|
||||
url: s.url || '',
|
||||
type: s.type || 'nexusphp',
|
||||
auth_type: s.auth_type || 'cookie',
|
||||
cookie: s.cookie || '',
|
||||
api_key: s.api_key || '',
|
||||
auth_header: s.auth_header || '',
|
||||
enabled: s.enabled !== false,
|
||||
is_default: s.is_default || false,
|
||||
extra: s.extra || '',
|
||||
})
|
||||
setAdvancedOpen(false)
|
||||
setShowModal(true)
|
||||
} catch {
|
||||
toast.error('获取站点详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false)
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
// ── 保存 ──
|
||||
const handleSave = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
toast.error('站点名称和地址不能为空')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name.trim(),
|
||||
url: form.url.trim(),
|
||||
type: form.type,
|
||||
auth_type: form.auth_type,
|
||||
cookie: form.cookie || '',
|
||||
api_key: form.api_key || '',
|
||||
auth_header: form.auth_header || '',
|
||||
enabled: form.enabled,
|
||||
is_default: form.is_default,
|
||||
extra: form.extra || '',
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await sitesAPI.update(editingId, payload)
|
||||
toast.success('站点已更新')
|
||||
} else {
|
||||
await sitesAPI.create(payload)
|
||||
toast.success('站点已添加')
|
||||
}
|
||||
closeModal()
|
||||
await loadSites()
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
'保存失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 测试 ──
|
||||
const handleTest = async (id: string) => {
|
||||
setTestingId(id)
|
||||
try {
|
||||
await sitesAPI.test(id)
|
||||
toast.success('连接测试成功')
|
||||
} catch {
|
||||
toast.error('连接测试失败')
|
||||
} finally {
|
||||
setTestingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 删除 ──
|
||||
const handleDelete = async (site: Site) => {
|
||||
if (!confirm(`确定要删除站点「${site.name}」吗?此操作不可撤销。`)) return
|
||||
try {
|
||||
await sitesAPI.remove(site.id)
|
||||
toast.success('站点已删除')
|
||||
await loadSites()
|
||||
} catch {
|
||||
toast.error('删除站点失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ── 站点类型切换时自动切换认证方式 ──
|
||||
const handleTypeChange = (t: string) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
type: t,
|
||||
auth_type: t === 'mteam' ? 'api_key' : f.auth_type,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display text-3xl font-bold text-white">站点管理</h1>
|
||||
<button onClick={openCreate} className="neon-button flex items-center gap-2">
|
||||
<Plus size={16} />
|
||||
添加站点
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 站点卡片网格 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sites.map((site) => (
|
||||
<div key={site.id} className="glass-panel p-4 space-y-3 transition-all hover:border-primary-400/30">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold shrink-0 ${SITE_TYPE_COLORS[site.type] || 'bg-slate-500/15 text-slate-400'}`}>
|
||||
{SITE_TYPE_ABBR[site.type] || '?'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-white truncate">{site.name}</div>
|
||||
<div className="text-xs text-slate-400 truncate max-w-[160px]">{site.url}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 状态指示 */}
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
{site.last_check_at ? (
|
||||
site.last_error ? (
|
||||
<XCircle size={14} className="text-red-400" />
|
||||
) : (
|
||||
<CheckCircle size={14} className="text-green-400" />
|
||||
)
|
||||
) : (
|
||||
<HelpCircle size={14} className="text-slate-500" />
|
||||
)}
|
||||
{!site.enabled && <span className="text-xs text-slate-500 ml-1">已停用</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 text-slate-400">
|
||||
{SITE_TYPE_LABELS[site.type] || site.type}
|
||||
</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-white/5 text-slate-400">
|
||||
{AUTH_TYPE_LABELS[site.auth_type] || site.auth_type}
|
||||
</span>
|
||||
{site.is_default && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary-400/15 text-primary-400">默认</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => handleTest(site.id)}
|
||||
disabled={testingId === site.id}
|
||||
className="flex-1 rounded border border-white/10 px-2 py-1.5 text-xs text-slate-300 hover:bg-white/5 disabled:opacity-50 flex items-center justify-center gap-1 transition"
|
||||
>
|
||||
{testingId === site.id ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
测试中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wifi size={12} />
|
||||
测试连接
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEdit(site.id)}
|
||||
className="rounded border border-white/10 p-1.5 text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(site)}
|
||||
className="rounded border border-white/10 p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-400/10 transition"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && sites.length === 0 && (
|
||||
<div className="col-span-full py-12 text-center text-slate-400">
|
||||
<Globe size={40} className="mx-auto mb-3 text-slate-600" />
|
||||
<p>暂无站点</p>
|
||||
<p className="text-sm mt-1 text-slate-500">点击「添加站点」添加 PT/BT 站点</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中 */}
|
||||
{loading && (
|
||||
<div className="col-span-full py-12 text-center text-slate-400">
|
||||
<RefreshCw size={24} className="mx-auto mb-3 animate-spin" />
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 创建/编辑弹窗 ── */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] bg-black/60 backdrop-blur-sm" onClick={closeModal}>
|
||||
<div
|
||||
className="glass-panel w-full max-w-xl max-h-[75vh] overflow-y-auto mx-4 space-y-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold text-white">
|
||||
{editingId ? '编辑站点' : '添加站点'}
|
||||
</h2>
|
||||
<button onClick={closeModal} className="text-slate-400 hover:text-white transition">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1.5">站点名称 *</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="例如: 馒头、观众、家园"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 地址 */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1.5">站点地址 *</label>
|
||||
<input
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="https://www.example.com/"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">格式: https://www.example.com/</p>
|
||||
</div>
|
||||
|
||||
{/* 站点类型 + 状态 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1.5">站点类型</label>
|
||||
<select
|
||||
className="input-base w-full"
|
||||
value={form.type}
|
||||
onChange={(e) => handleTypeChange(e.target.value)}
|
||||
>
|
||||
<option value="nexusphp">NexusPHP(国内主流PT)</option>
|
||||
<option value="gazelle">Gazelle(HDBits等)</option>
|
||||
<option value="unit3d">UNIT3D(BeyondHD等)</option>
|
||||
<option value="mteam">馒头 M-Team(专用API)</option>
|
||||
<option value="discuz">Discuz 论坛型</option>
|
||||
<option value="custom_rss">自定义 RSS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1.5">状态</label>
|
||||
<div className="flex items-center gap-3 h-10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, enabled: !f.enabled }))}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.enabled ? 'bg-primary-500' : 'bg-white/10'}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.enabled ? 'translate-x-4' : 'translate-x-0.5'}`}
|
||||
/>
|
||||
</button>
|
||||
<span className={`text-sm ${form.enabled ? 'text-white' : 'text-slate-500'}`}>
|
||||
{form.enabled ? '启用' : '停用'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 馒头提示 */}
|
||||
{form.type === 'mteam' && (
|
||||
<div className="p-3 rounded-lg border border-green-500/30 bg-green-500/5">
|
||||
<div className="text-sm font-medium text-green-400 mb-1">馒头站点配置指南</div>
|
||||
<div className="text-xs text-slate-400 space-y-1">
|
||||
<div><b>站点地址:</b><code className="text-green-300">https://api2.m-team.cc</code></div>
|
||||
<div><b>认证方式:</b>推荐使用「API Key / Passkey」</div>
|
||||
<div className="pl-3 text-slate-500">
|
||||
1. 登录馒头站 → 控制台 → 实验室 → 存取令牌<br />
|
||||
2. 点击「创建令牌」,复制生成的 Token<br />
|
||||
3. 将 Token 填入下方「令牌」输入框
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 认证方式 */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-2">认证方式</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{[
|
||||
{ value: 'cookie', label: 'Cookie' },
|
||||
{ value: 'api_key', label: 'API Key' },
|
||||
{ value: 'auth_header', label: 'Auth Header' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, auth_type: opt.value }))}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition ${
|
||||
form.auth_type === opt.value
|
||||
? 'bg-primary-500 text-white border-primary-500'
|
||||
: 'border-white/10 text-slate-400 hover:border-primary-500/50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.auth_type === 'cookie' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">Cookie</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base w-full resize-none text-xs font-mono"
|
||||
placeholder="uid=xxx; pass=xxx; ..."
|
||||
value={form.cookie}
|
||||
onChange={(e) => setForm((f) => ({ ...f, cookie: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">从浏览器开发者工具的请求头中获取 Cookie 值</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === 'api_key' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">令牌(API Key / Passkey)</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-base w-full font-mono text-sm"
|
||||
placeholder="输入 API Key 或 Passkey"
|
||||
value={form.api_key}
|
||||
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
{form.type === 'mteam'
|
||||
? '馒头:控制台 → 实验室 → 存取令牌'
|
||||
: '站点的访问 API Key'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.auth_type === 'auth_header' && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">请求头(Authorization)</label>
|
||||
<input
|
||||
className="input-base w-full font-mono text-xs"
|
||||
placeholder="Bearer eyJhbGciOiJIUzI1NiIs..."
|
||||
value={form.auth_header}
|
||||
onChange={(e) => setForm((f) => ({ ...f, auth_header: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 高级选项 */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen(!advancedOpen)}
|
||||
className="flex items-center gap-1 text-xs text-slate-400 hover:text-white transition"
|
||||
>
|
||||
{advancedOpen ? '▾' : '▸'} 高级选项
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<div className="mt-3 pl-4 space-y-3 border-l border-white/10">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">Extra 扩展配置 (JSON)</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input-base w-full resize-none text-xs font-mono"
|
||||
placeholder='{"key":"value"}'
|
||||
value={form.extra}
|
||||
onChange={(e) => setForm((f) => ({ ...f, extra: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, is_default: !f.is_default }))}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors cursor-pointer ${form.is_default ? 'bg-primary-500' : 'bg-white/10'}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${form.is_default ? 'translate-x-4' : 'translate-x-0.5'}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-slate-400">设为默认站点</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={closeModal} className="rounded border border-white/10 px-4 py-2 text-sm text-slate-300 hover:bg-white/5 transition">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !form.name.trim() || !form.url.trim()}
|
||||
className="neon-button text-sm disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
'保存'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+69
-6
@@ -2,26 +2,89 @@ import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
import type { User } from '../types'
|
||||
import { refreshToken } from '../api/refresh'
|
||||
|
||||
// Single source of truth for the authenticated user + JWT.
|
||||
// Persisted to localStorage so a page reload does not drop the session.
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
refreshToken: string | null
|
||||
user: User | null
|
||||
setSession: (token: string, user: User) => void
|
||||
tier: string
|
||||
setSession: (token: string, refreshToken: string, user: User) => void
|
||||
setUser: (user: User) => void
|
||||
setToken: (token: string) => void
|
||||
setRefreshToken: (refreshToken: string) => void
|
||||
logout: () => void
|
||||
tokenRefresh: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
setSession: (token, user) => set({ token, user }),
|
||||
setUser: (user) => set({ user }),
|
||||
logout: () => set({ token: null, user: null }),
|
||||
tier: 'free',
|
||||
setSession: (token, refreshToken, user) => set({
|
||||
token,
|
||||
refreshToken,
|
||||
user,
|
||||
tier: user.tier || 'free'
|
||||
}),
|
||||
setUser: (user) => set({ user, tier: user.tier || 'free' }),
|
||||
setToken: (token) => set({ token }),
|
||||
setRefreshToken: (refreshToken) => set({ refreshToken }),
|
||||
logout: () => set({ token: null, refreshToken: null, user: null, tier: 'free' }),
|
||||
tokenRefresh: async () => {
|
||||
const rt = get().refreshToken
|
||||
if (!rt) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const resp = await refreshToken(rt)
|
||||
set({
|
||||
token: resp.token,
|
||||
refreshToken: resp.refresh_token
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
// Refresh failed, need to logout
|
||||
set({ token: null, refreshToken: null, user: null, tier: 'free' })
|
||||
return false
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: 'mediastationgo-auth' },
|
||||
{
|
||||
name: 'mediastationgo-auth',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
refreshToken: state.refreshToken,
|
||||
user: state.user,
|
||||
tier: state.tier
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Helper function to check if user is authenticated
|
||||
export function isAuthenticated(): boolean {
|
||||
return useAuthStore.getState().token !== null
|
||||
}
|
||||
|
||||
// Helper function to check if user is admin
|
||||
export function isAdmin(): boolean {
|
||||
const user = useAuthStore.getState().user
|
||||
return user?.role === 'admin'
|
||||
}
|
||||
|
||||
// Helper function to check if user is plus
|
||||
export function isPlus(): boolean {
|
||||
const state = useAuthStore.getState()
|
||||
return state.tier === 'plus' || state.user?.role === 'admin'
|
||||
}
|
||||
|
||||
// Helper function to check if user is super user (admin or plus)
|
||||
export function isSuperUser(): boolean {
|
||||
return isAdmin() || isPlus()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
import { getMyPermissions } from '../api/permission'
|
||||
|
||||
interface PermissionState {
|
||||
permissions: Record<string, boolean>
|
||||
role: string
|
||||
tier: string
|
||||
isSuper: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
fetchPermissions: () => Promise<void>
|
||||
hasPermission: (key: string) => boolean
|
||||
clearPermissions: () => void
|
||||
}
|
||||
|
||||
export const usePermissionStore = create<PermissionState>((set, get) => ({
|
||||
permissions: {},
|
||||
role: '',
|
||||
tier: 'free',
|
||||
isSuper: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchPermissions: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const result = await getMyPermissions()
|
||||
set({
|
||||
permissions: result.permissions,
|
||||
role: result.role,
|
||||
tier: result.tier,
|
||||
isSuper: result.is_super,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch permissions',
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
hasPermission: (key: string) => {
|
||||
const state = get()
|
||||
// Super user (admin or plus) has all permissions
|
||||
if (state.isSuper) {
|
||||
return true
|
||||
}
|
||||
return state.permissions[key] === true
|
||||
},
|
||||
|
||||
clearPermissions: () => {
|
||||
set({
|
||||
permissions: {},
|
||||
role: '',
|
||||
tier: 'free',
|
||||
isSuper: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
// Default permissions for new users (without fetching from server)
|
||||
export const defaultPermissions: Record<string, boolean> = {
|
||||
can_view_dashboard: true,
|
||||
can_play_media: true,
|
||||
can_cast: true,
|
||||
can_external_player: true,
|
||||
can_favorite: true,
|
||||
can_view_history: true,
|
||||
can_edit_media: false,
|
||||
can_rescrape: false,
|
||||
can_use_ai: false,
|
||||
can_capture_frames: false,
|
||||
can_manage_downloads: false,
|
||||
can_view_discover: false,
|
||||
can_manage_subscriptions: false,
|
||||
can_manage_sites: false,
|
||||
can_use_ai_assistant: false,
|
||||
can_manage_users: false,
|
||||
can_manage_files: false,
|
||||
can_manage_strm: false,
|
||||
can_access_settings: false,
|
||||
}
|
||||
|
||||
// Permission display names for UI
|
||||
export const permissionDisplayNames: Record<string, string> = {
|
||||
can_view_dashboard: '查看仪表盘',
|
||||
can_play_media: '播放媒体',
|
||||
can_cast: '投屏',
|
||||
can_external_player: '外部播放器',
|
||||
can_favorite: '收藏',
|
||||
can_view_history: '观看历史',
|
||||
can_edit_media: '编辑媒体',
|
||||
can_rescrape: '重新刮削',
|
||||
can_use_ai: '使用 AI 搜索',
|
||||
can_capture_frames: '截图',
|
||||
can_manage_downloads: '管理下载',
|
||||
can_view_discover: '发现',
|
||||
can_manage_subscriptions: '管理订阅',
|
||||
can_manage_sites: '管理站点',
|
||||
can_use_ai_assistant: 'AI 助手',
|
||||
can_manage_users: '管理用户',
|
||||
can_manage_files: '管理文件',
|
||||
can_manage_strm: '管理 STRM',
|
||||
can_access_settings: '访问设置',
|
||||
}
|
||||
|
||||
// Permission categories for grouping
|
||||
export const permissionCategories = {
|
||||
basic: [
|
||||
'can_view_dashboard',
|
||||
'can_play_media',
|
||||
'can_cast',
|
||||
'can_external_player',
|
||||
'can_favorite',
|
||||
'can_view_history',
|
||||
],
|
||||
media: [
|
||||
'can_edit_media',
|
||||
'can_rescrape',
|
||||
'can_capture_frames',
|
||||
'can_manage_files',
|
||||
'can_manage_strm',
|
||||
],
|
||||
advanced: [
|
||||
'can_use_ai',
|
||||
'can_use_ai_assistant',
|
||||
'can_view_discover',
|
||||
'can_manage_downloads',
|
||||
'can_manage_subscriptions',
|
||||
'can_manage_sites',
|
||||
],
|
||||
admin: [
|
||||
'can_manage_users',
|
||||
'can_access_settings',
|
||||
],
|
||||
}
|
||||
@@ -4,14 +4,92 @@ export interface User {
|
||||
id: string
|
||||
username: string
|
||||
role: 'admin' | 'user'
|
||||
tier: 'free' | 'plus'
|
||||
nickname?: string
|
||||
email?: string
|
||||
avatar_url?: string
|
||||
force_password_reset: boolean
|
||||
is_active: boolean
|
||||
last_login_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Token pair
|
||||
export interface TokenPair {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// User permission (19 granular permissions)
|
||||
export interface UserPermission {
|
||||
id: string
|
||||
user_id: string
|
||||
// Default enabled (6)
|
||||
can_view_dashboard: boolean
|
||||
can_play_media: boolean
|
||||
can_cast: boolean
|
||||
can_external_player: boolean
|
||||
can_favorite: boolean
|
||||
can_view_history: boolean
|
||||
// Default disabled (13)
|
||||
can_edit_media: boolean
|
||||
can_rescrape: boolean
|
||||
can_use_ai: boolean
|
||||
can_capture_frames: boolean
|
||||
can_manage_downloads: boolean
|
||||
can_view_discover: boolean
|
||||
can_manage_subscriptions: boolean
|
||||
can_manage_sites: boolean
|
||||
can_use_ai_assistant: boolean
|
||||
can_manage_users: boolean
|
||||
can_manage_files: boolean
|
||||
can_manage_strm: boolean
|
||||
can_access_settings: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// API config
|
||||
export interface ApiConfig {
|
||||
id: string
|
||||
provider: string
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
extra?: string
|
||||
enabled: boolean
|
||||
description?: string
|
||||
last_tested_at?: string
|
||||
test_result?: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// API provider
|
||||
export interface ApiProvider {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
has_api_key: boolean
|
||||
has_base_url: boolean
|
||||
}
|
||||
|
||||
// Refresh token response
|
||||
export interface RefreshTokenResponse {
|
||||
token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// Login response
|
||||
export interface LoginResponse {
|
||||
user: User
|
||||
tokens: TokenPair
|
||||
}
|
||||
|
||||
// Library
|
||||
export interface Library {
|
||||
id: string
|
||||
name: string
|
||||
@@ -140,3 +218,107 @@ export interface StatsSnapshot {
|
||||
hardware: Hardware
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
// SSE Event types
|
||||
export interface SSEEvent {
|
||||
type: string
|
||||
payload: unknown
|
||||
}
|
||||
|
||||
// Permission check result
|
||||
export interface PermissionCheckResult {
|
||||
permissions: Record<string, boolean>
|
||||
role: string
|
||||
tier: string
|
||||
is_super: boolean
|
||||
}
|
||||
|
||||
// Download Client
|
||||
export interface DownloadClient {
|
||||
id: string
|
||||
name: string
|
||||
type: 'qbittorrent' | 'transmission' | 'aria2'
|
||||
host: string
|
||||
username: string
|
||||
is_default: boolean
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Download Client type info
|
||||
export interface DownloadClientTypeInfo {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// Notify Channel
|
||||
export interface NotifyChannel {
|
||||
id: string
|
||||
name: string
|
||||
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
||||
enabled: boolean
|
||||
events: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Notify Provider type info
|
||||
export interface NotifyProviderInfo {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// Scheduler task config
|
||||
export interface SchedulerTaskConfig {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
interval: number
|
||||
last_run_at?: string
|
||||
next_run_at?: string
|
||||
}
|
||||
|
||||
// Scheduler status
|
||||
export interface SchedulerStatus {
|
||||
running: boolean
|
||||
started_at?: string
|
||||
task_count: number
|
||||
tasks: SchedulerTaskConfig[]
|
||||
}
|
||||
|
||||
// Site configuration
|
||||
export interface Site {
|
||||
id: string
|
||||
name: string
|
||||
type: string // nexusphp / gazelle / unit3d / mteam / discuz / custom_rss
|
||||
url: string
|
||||
auth_type: string // cookie / api_key / auth_header
|
||||
cookie?: string // decrypted only in detail view
|
||||
api_key?: string // decrypted only in detail view
|
||||
auth_header?: string // decrypted only in detail view
|
||||
enabled: boolean
|
||||
is_default: boolean
|
||||
extra?: string
|
||||
last_error?: string
|
||||
last_check_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Site type info
|
||||
export interface SiteTypeInfo {
|
||||
value: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// Auth type info
|
||||
export interface AuthTypeInfo {
|
||||
value: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user