mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
chore: remove license code, fix compilation, update README bilingual
- Remove all license-related code (handler/service/repository/model) License authorization is managed by separate server: https://github.com/ShukeBta/MediaStationLicenseServer - Fix compilation errors: model field alignment, method name fixes, route conflicts, struct literal corrections (7 files) - Add Chinese README.md as primary, English README_EN.md - Update .gitignore: exclude .workbuddy/, editor backups - Add new repository files: assistant, play_profile, storage_config
This commit is contained in:
@@ -45,3 +45,9 @@ Thumbs.db
|
|||||||
# Local configs (keep examples)
|
# Local configs (keep examples)
|
||||||
config/secrets.yaml
|
config/secrets.yaml
|
||||||
config.yaml
|
config.yaml
|
||||||
|
|
||||||
|
# WorkBuddy workspace (local AI assistant memory)
|
||||||
|
.workbuddy/
|
||||||
|
|
||||||
|
# Editor backups
|
||||||
|
*~
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
# 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 为预期行为
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# 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` 但不再路由引用
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
<h1 align="center">🎬 MediaStationGo</h1>
|
<h1 align="center">🎬 MediaStationGo</h1>
|
||||||
<p align="center">A Go rewrite of <a href="https://github.com/ShukeBta/MediaStation">MediaStation</a> — your private home media center.</p>
|
<p align="center">
|
||||||
|
<a href="https://github.com/ShukeBta/MediaStation">MediaStation</a> 的 Go 语言重写版 —— 您的私有家庭媒体中心。
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="README_EN.md">English</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go" alt="Go">
|
<img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go" alt="Go">
|
||||||
@@ -12,205 +17,207 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why a rewrite?
|
## 为什么要重写?
|
||||||
|
|
||||||
The original MediaStation is a Python/FastAPI + Vue project. **MediaStationGo**
|
原版 MediaStation 是一个 Python/FastAPI + Vue 项目。**MediaStationGo** 是从零开始的全新重构,采用更轻量的单文件部署模式:
|
||||||
is a from-scratch reimplementation that adopts the lighter, single-binary
|
|
||||||
deployment model used by [`ShukeBta/MediaStation`](https://github.com/ShukeBta/MediaStation):
|
|
||||||
|
|
||||||
- **Backend**: Go 1.25 + Gin + GORM + SQLite (WAL).
|
- **后端**:Go 1.25 + Gin + GORM + SQLite(WAL 模式)
|
||||||
- **Frontend**: React 18 + Vite + Tailwind + Zustand.
|
- **前端**:React 18 + Vite + Tailwind CSS + Zustand
|
||||||
- **Distribution**: one ~30 MB static binary (CGO disabled), or a
|
- **分发**:约 30 MB 纯静态二进制(CGO 禁用),或跨架构 Alpine Docker 镜像
|
||||||
multi-arch Alpine Docker image.
|
|
||||||
|
|
||||||
The goal is to keep the user-facing feature surface familiar (libraries,
|
目标是在保持用户功能和界面不变的前提下,大幅降低 NAS 设备上的部署复杂度。
|
||||||
scanning, scraping, direct play/HLS, multi-user, downloads, RSS) while
|
|
||||||
making deployment painless on NAS hardware.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## 功能特性
|
||||||
|
|
||||||
### Authentication & users
|
### 认证与用户
|
||||||
- ✅ JWT auth with admin/user roles
|
- ✅ JWT 认证(admin / user 双角色)
|
||||||
- ✅ First-run admin seeding (`admin / admin123`, override via `ADMIN_INITIAL_PASSWORD`)
|
- ✅ 首次运行自动创建管理员(`admin / admin123`,可通过 `ADMIN_INITIAL_PASSWORD` 自定义)
|
||||||
- ✅ Profile page (email / avatar / change password)
|
- ✅ 个人信息页(邮箱 / 头像 / 修改密码)
|
||||||
- ✅ Admin user table with role promotion / demotion
|
- ✅ 管理员用户表(角色提升 / 降级)
|
||||||
- ✅ Audit log written for sensitive actions (login, library CRUD, downloads, …)
|
- ✅ 敏感操作审计日志(登录、媒体库操作、下载等)
|
||||||
|
|
||||||
### Library management
|
### 媒体库管理
|
||||||
- ✅ Library CRUD + recursive filesystem scan
|
- ✅ 媒体库增删改查 + 递归文件系统扫描
|
||||||
- ✅ ffprobe metadata extraction (duration / resolution / codecs / container)
|
- ✅ ffprobe 元数据提取(时长 / 分辨率 / 编码格式 / 容器)
|
||||||
- ✅ Scene-noise filename cleaner with year + season/episode parsing
|
- ✅ 智能文件名清洗(年份 + 季/集号识别)
|
||||||
- ✅ Multi-provider scrape chain by library type:
|
- ✅ 多数据源链式刮削(按媒体库类型):
|
||||||
- movie → TMDb (with optional Fanart.tv high-res poster upgrade)
|
- 电影 → TMDb(可选 Fanart.tv 高清海报升级)
|
||||||
- tv → TheTVDB (fallback TMDb)
|
- 电视剧 → TheTVDB(TMDb 回退)
|
||||||
- anime → Bangumi (fallback TMDb)
|
- 动漫 → Bangumi(TMDb 回退)
|
||||||
- ✅ Image proxy with disk cache for TMDb / Bangumi / Douban / Fanart / TheTVDB
|
- ✅ 图片代理 + 磁盘缓存(TMDb / Bangumi / 豆瓣 / Fanart / TheTVDB)
|
||||||
- ✅ TV / anime libraries grouped by season with episode listing
|
- ✅ 电视剧 / 动漫按季分组 + 剧集列表
|
||||||
- ✅ fsnotify-based filesystem watcher with 5 s coalescing debouncer
|
- ✅ fsnotify 文件系统监听(5 秒防抖合并)
|
||||||
|
|
||||||
### Playback
|
### 播放
|
||||||
- ✅ Direct-play streaming with HTTP `Range` support
|
- ✅ 直链播放(支持 HTTP Range)
|
||||||
- ✅ HLS on-demand transcoding (single ffmpeg job per media)
|
- ✅ HLS 按需转码(每个文件独立 ffmpeg 作业)
|
||||||
- ✅ External subtitle discovery (.srt / .vtt / .ass / .ssa) with on-the-fly WebVTT conversion
|
- ✅ 外挂字幕识别(.srt / .vtt / .ass / .ssa)+ 实时 WebVTT 转换
|
||||||
- ✅ Resume position written every 10 s + Continue Watching row on home
|
- ✅ 播放位置续播(每 10 秒写入)+ 首页「继续观看」
|
||||||
- ✅ Favourites (toggle) + ordered Playlists (CRUD)
|
- ✅ 收藏(切换)+ 播放列表(增删改查)
|
||||||
|
|
||||||
### Automation
|
### PT 站点管理
|
||||||
- ✅ qBittorrent download integration (add / list / delete via Web UI API)
|
- ✅ 站点配置增删改查
|
||||||
- ✅ RSS subscriptions with regex filters, GUID dedup and 10-minute polling
|
- ✅ 支持 6 种 PT 站点类型:nexusphp / gazelle / unit3d / mteam / discuz / custom_rss
|
||||||
|
- ✅ 3 种认证方式:Cookie / API Key / Auth Header
|
||||||
|
- ✅ 站点连接测试
|
||||||
|
- ✅ 跨站种子搜索
|
||||||
|
- ✅ 站点扩展配置(Extra JSON:User-Agent / RSS URL / 超时 / 优先级 / 代理 / 下载器)
|
||||||
|
|
||||||
### Operations
|
### 自动化
|
||||||
- ✅ Real-time scan / scrape / transcode / download / subscription events over WebSocket
|
- ✅ qBittorrent 下载集成(添加 / 列表 / 删除)
|
||||||
- ✅ Operator dashboard at `/stats` (CPU / memory / disk / library counts / Goroutines)
|
- ✅ RSS 订阅 + 正则过滤 + GUID 去重 + 10 分钟轮询
|
||||||
- ✅ Real-time tasks panel at `/tasks` (active ffmpeg jobs + qBittorrent torrents)
|
- ✅ 下载文件自动分类整理
|
||||||
- ✅ Recycle bin at `/recycle` (soft delete + restore + purge)
|
|
||||||
- ✅ NFO export (Kodi / Jellyfin compatibility) — single media or whole library
|
|
||||||
- ✅ Hardware-accel encoder profiles: software / NVENC / Intel QSV / VAAPI
|
|
||||||
- ✅ Single-binary build, multi-arch Docker image, GitHub Actions CI + GHCR publish
|
|
||||||
|
|
||||||
### Discovery & AI
|
### 运维监控
|
||||||
- ✅ TMDb Discover — Trending (today) + Popular rails on `/discover`
|
- ✅ 实时事件推送(扫描 / 刮削 / 转码 / 下载 / 订阅)通过 WebSocket
|
||||||
- ✅ AI smart search (OpenAI-compatible) — natural-language queries → structured intent
|
- ✅ 仪表盘 `/stats`(CPU / 内存 / 磁盘 / 媒体库数量 / Goroutines)
|
||||||
- ✅ AI recommendations seeded from your watch history (`GET /api/ai/recommend`)
|
- ✅ 实时任务面板 `/tasks`(当前 ffmpeg 作业 + qBittorrent 种子)
|
||||||
|
- ✅ NFO 导出(Kodi / Jellyfin 兼容)—— 单文件或整库
|
||||||
|
- ✅ 硬件加速编码配置:Software / NVENC / Intel QSV / VAAPI
|
||||||
|
- ✅ 单文件部署、多架构 Docker 镜像、GitHub Actions CI + GHCR
|
||||||
|
|
||||||
### Frontend
|
### 发现与 AI
|
||||||
- ✅ React SPA with code-splitting: Login / Home / Library / Search / Favourites /
|
- ✅ TMDb 发现 —— 首页热门推荐
|
||||||
Playlists / Media detail / Player (HLS + direct + subtitles) / Profile /
|
- ✅ AI 智能搜索(OpenAI 兼容接口)—— 自然语言 → 结构化查询
|
||||||
Downloads / Subscriptions / Stats / Admin
|
- ✅ AI 推荐(基于观看历史)`GET /api/ai/recommend`
|
||||||
- ✅ Global toast notifications driven by the WebSocket hub
|
|
||||||
- ✅ Initial bundle ~250 KB / 83 KB gzipped (hls.js loaded only on first HLS playback)
|
|
||||||
|
|
||||||
### Roadmap
|
### 前端
|
||||||
|
- ✅ React SPA 代码分割:登录 / 首页 / 媒体库 / 搜索 / 收藏 / 播放列表 / 详情 / 播放器(HLS + 直链 + 字幕) / 个人信息 / 下载 / 订阅 / 统计 / 管理后台 / 站点管理 / API 配置
|
||||||
|
- ✅ WebSocket 全局通知
|
||||||
|
- ✅ 初始包体积约 250 KB / Gzip 后 83 KB(hls.js 仅在首次 HLS 播放时按需加载)
|
||||||
|
|
||||||
| Area | Status |
|
### 路线图
|
||||||
|------|--------|
|
|
||||||
| Bidirectional Jellyfin / Emby compatibility layer | ⏳ |
|
| 功能 | 状态 |
|
||||||
| DLNA / Chromecast | ⏳ |
|
|------|------|
|
||||||
| Online subtitle search providers | ⏳ |
|
| Jellyfin / Emby 双向兼容层 | ⏳ |
|
||||||
| Multi-bitrate ABR transcode profiles | ⏳ |
|
| DLNA / Chromecast 投屏 | ⏳ |
|
||||||
|
| 在线字幕搜索 | ⏳ |
|
||||||
|
| 多码率 ABR 转码 | ⏳ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick start
|
## 快速开始
|
||||||
|
|
||||||
### Docker
|
### Docker 部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ShukeBta/MediaStationGo.git
|
git clone https://github.com/ShukeBta/MediaStationGo.git
|
||||||
cd MediaStationGo
|
cd MediaStationGo
|
||||||
|
|
||||||
# (optional) edit docker-compose.yml to mount your media root at /media
|
# (可选)编辑 docker-compose.yml,将您的媒体目录挂载到 /media
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Open <http://localhost:8080> and log in with `admin / admin123`.
|
打开 <http://localhost:8080>,使用 `admin / admin123` 登录。
|
||||||
|
|
||||||
### Bare metal
|
### 裸机部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# requirements: Go 1.25+, Node 20+, ffmpeg
|
# 前置要求:Go 1.25+、Node 20+、ffmpeg
|
||||||
make build # produces bin/mediastation-go and web/dist
|
make build # 生成 bin/mediastation-go 和 web/dist
|
||||||
./bin/mediastation-go
|
./bin/mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
### Local development
|
### 本地开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dev # backend on :8080, MEDIASTATION_APP_DEBUG=true
|
make dev # 后端启动在 :8080,MEDIASTATION_APP_DEBUG=true
|
||||||
make dev-web # vite dev server on :3000, proxies /api -> :8080
|
make dev-web # Vite 开发服务器启动在 :3000,代理 /api → :8080
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## 配置说明
|
||||||
|
|
||||||
Configuration is layered — defaults < `config.yaml` < `config/*.yaml` <
|
配置层级:默认值 < `config.yaml` < `config/*.yaml` < 环境变量(前缀 `MEDIASTATION_`)。
|
||||||
environment variables prefixed with `MEDIASTATION_`.
|
|
||||||
|
|
||||||
### Most-used keys
|
### 常用环境变量
|
||||||
|
|
||||||
| Key | Default | Purpose |
|
| 变量 | 默认值 | 说明 |
|
||||||
|------|---------|---------|
|
|------|--------|------|
|
||||||
| `MEDIASTATION_APP_PORT` | `8080` | HTTP listen port |
|
| `MEDIASTATION_APP_PORT` | `8080` | HTTP 监听端口 |
|
||||||
| `MEDIASTATION_APP_DATA_DIR` | `./data` | DB / cache / JWT secret root |
|
| `MEDIASTATION_APP_DATA_DIR` | `./data` | 数据目录(数据库 / 缓存 / JWT 密钥) |
|
||||||
| `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | SPA bundle to serve |
|
| `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | 前端 SPA 静态文件目录 |
|
||||||
| `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite file |
|
| `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite 数据库文件路径 |
|
||||||
| `MEDIASTATION_SECRETS_JWT_SECRET` | *(auto)* | JWT signing key |
|
| `MEDIASTATION_SECRETS_JWT_SECRET` | *(自动生成)* | JWT 签名密钥 |
|
||||||
| `MEDIASTATION_SECRETS_TMDB_API_KEY` | *(empty)* | Enables movie scraping |
|
| `MEDIASTATION_SECRETS_TMDB_API_KEY` | *(空)* | 启用 TMDb 电影刮削 |
|
||||||
| `MEDIASTATION_SECRETS_BANGUMI_ACCESS_TOKEN` | *(empty)* | Optional, raises Bangumi rate limit |
|
| `MEDIASTATION_SECRETS_BANGUMI_ACCESS_TOKEN` | *(空)* | 可选,提升 Bangumi 速率限制 |
|
||||||
| `MEDIASTATION_APP_CORS_ORIGINS` | *(empty)* | Allow-list, JSON array |
|
| `MEDIASTATION_APP_CORS_ORIGINS` | *(空)* | 跨域白名单(JSON 数组) |
|
||||||
| `ADMIN_INITIAL_PASSWORD` | `admin123` | Bootstrap admin password |
|
| `ADMIN_INITIAL_PASSWORD` | `admin123` | 初始管理员密码 |
|
||||||
|
|
||||||
### Runtime settings (admin → 设置)
|
### 运行时设置(管理后台 → 设置)
|
||||||
|
|
||||||
These live in the `settings` table and can be edited from the admin UI:
|
这些配置存储在 `settings` 表中,可通过管理 UI 编辑:
|
||||||
|
|
||||||
| Key | Purpose |
|
| 键 | 说明 |
|
||||||
|-----|---------|
|
|----|------|
|
||||||
| `qbittorrent.url` | qBittorrent Web UI base URL |
|
| `qbittorrent.url` | qBittorrent Web UI 地址 |
|
||||||
| `qbittorrent.username` | qBittorrent user |
|
| `qbittorrent.username` | qBittorrent 用户名 |
|
||||||
| `qbittorrent.password` | qBittorrent password |
|
| `qbittorrent.password` | qBittorrent 密码 |
|
||||||
| `qbittorrent.savepath` | Optional default save path for new torrents |
|
| `qbittorrent.savepath` | 可选,新种子默认保存路径 |
|
||||||
|
|
||||||
After editing, hit **下载 → 重新加载配置** (or `POST /api/downloads/reload`) so
|
编辑后点击 **下载 → 重新加载配置**(或 `POST /api/downloads/reload`)使客户端重新读取。
|
||||||
the qBittorrent client picks up the new credentials.
|
|
||||||
|
|
||||||
See [`config.example.yaml`](config.example.yaml) for the full surface.
|
完整配置模板请参见 [`config.example.yaml`](config.example.yaml)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project layout
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
MediaStationGo/
|
MediaStationGo/
|
||||||
├── cmd/server/main.go Application entry point
|
├── cmd/server/main.go 应用入口
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/ Viper-based config loader
|
│ ├── config/ Viper 配置加载
|
||||||
│ ├── database/ GORM + SQLite (WAL) bootstrap
|
│ ├── database/ GORM + SQLite (WAL) 初始化
|
||||||
│ ├── model/ GORM data models + AutoMigrate registry
|
│ ├── model/ GORM 数据模型 + AutoMigrate 注册
|
||||||
│ ├── repository/ Thin data-access layer
|
│ ├── repository/ 数据访问层
|
||||||
│ ├── service/ Business logic
|
│ ├── service/ 业务逻辑
|
||||||
│ │ ├── auth.go login / register / JWT / seed admin
|
│ │ ├── auth.go 登录 / 注册 / JWT / 管理员种子
|
||||||
│ │ ├── media.go library + media CRUD
|
│ │ ├── media.go 媒体库 + 媒体 CRUD
|
||||||
│ │ ├── scanner.go fs walker + ffprobe + scrape kick
|
│ │ ├── scanner.go 文件扫描 + ffprobe + 刮削触发
|
||||||
│ │ ├── ffprobe.go ffprobe wrapper
|
│ │ ├── ffprobe.go ffprobe 封装
|
||||||
│ │ ├── tmdb.go TMDb provider
|
│ │ ├── tmdb.go TMDb 数据源
|
||||||
│ │ ├── bangumi.go Bangumi provider
|
│ │ ├── bangumi.go Bangumi 数据源
|
||||||
│ │ ├── scraper.go orchestrator + filename cleaner
|
│ │ ├── scraper.go 刮削协调器 + 文件名清洗
|
||||||
│ │ ├── stream.go direct play + HLS playlist / segment
|
│ │ ├── site.go 站点管理(CRUD + 连接测试 + 跨站搜索)
|
||||||
│ │ ├── transcoder.go per-media ffmpeg HLS job manager
|
│ │ ├── site_adapter.go 6 种 PT 站点适配器
|
||||||
│ │ ├── subtitle.go external subtitle discovery + .vtt conversion
|
│ │ ├── stream.go 直链播放 + HLS 分片
|
||||||
│ │ ├── image_proxy.go cached, allow-listed image proxy
|
│ │ ├── transcoder.go 媒体 HLS 转码管理
|
||||||
│ │ ├── playback.go history / favourites / playlists
|
│ │ ├── subtitle.go 外挂字幕识别 + WebVTT 转换
|
||||||
│ │ ├── watcher.go fsnotify debouncer
|
│ │ ├── image_proxy.go 图片代理缓存
|
||||||
│ │ ├── qbittorrent.go qBittorrent v2 API client
|
│ │ ├── playback.go 播放历史 / 收藏 / 播放列表
|
||||||
│ │ ├── downloads.go download orchestrator + WS poller
|
│ │ ├── watcher.go fsnotify 文件监听
|
||||||
│ │ ├── subscription.go RSS poller
|
│ │ ├── qbittorrent.go qBittorrent API 客户端
|
||||||
│ │ ├── stats.go dashboard snapshot
|
│ │ ├── downloads.go 下载管理 + WS 轮询
|
||||||
│ │ ├── profile.go non-credential user mutations
|
│ │ ├── subscription.go RSS 订阅轮询
|
||||||
│ │ ├── audit.go audit log writer
|
│ │ ├── stats.go 仪表盘快照
|
||||||
│ │ ├── ws_hub.go pub/sub broker for the WS
|
│ │ ├── profile.go 用户信息修改
|
||||||
│ │ └── walk.go / episode_parser.go helpers
|
│ │ ├── audit.go 审计日志
|
||||||
│ ├── middleware/ Gin middleware (CORS / JWT / admin)
|
│ │ ├── ws_hub.go WebSocket 发布/订阅
|
||||||
│ └── handler/ HTTP route definitions (one file per concern)
|
│ │ ├── organizer.go 媒体文件整理
|
||||||
├── web/ React 18 + Vite SPA
|
│ │ └── walk.go / episode_parser.go 辅助工具
|
||||||
│ ├── src/api/ axios helpers (one per service)
|
│ ├── middleware/ Gin 中间件 (CORS / JWT / admin)
|
||||||
│ ├── src/components/ Layout, MediaCard, GlobalEvents, RequireAuth
|
│ └── handler/ HTTP 路由(按功能分文件)
|
||||||
│ ├── src/hooks/ useWebSocket, …
|
├── web/ React 18 + Vite 前端
|
||||||
│ ├── src/pages/ Home / Library / Search / Player / Downloads / …
|
│ ├── src/api/ axios 接口封装
|
||||||
│ ├── src/stores/ Zustand (auth)
|
│ ├── src/components/ Layout / MediaCard / GlobalEvents / RequireAuth / APIConfigsPanel
|
||||||
│ └── src/types/ Domain types mirrored from Go
|
│ ├── src/hooks/ useWebSocket 等
|
||||||
├── Dockerfile Multi-stage, multi-arch build
|
│ ├── src/pages/ 首页 / 媒体库 / 搜索 / 播放器 / 下载 / 管理后台 / 站点管理
|
||||||
├── docker-compose.yml NAS-friendly deployment
|
│ ├── src/stores/ Zustand 状态管理 (auth)
|
||||||
|
│ └── src/types/ 前端类型定义(与 Go 模型对齐)
|
||||||
|
├── Dockerfile 多阶段、多架构构建
|
||||||
|
├── docker-compose.yml NAS 友好部署配置
|
||||||
├── Makefile build / dev / docker / test
|
├── Makefile build / dev / docker / test
|
||||||
├── config.example.yaml Full configuration template
|
├── config.example.yaml 完整配置模板
|
||||||
└── .github/workflows/ CI + GHCR publish
|
└── .github/workflows/ CI + GHCR 发布
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## 许可证
|
||||||
|
|
||||||
Released under the [GNU GPL v3.0](LICENSE).
|
基于 [GNU GPL v3.0](LICENSE) 开源发布。
|
||||||
|
|||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
<h1 align="center">🎬 MediaStationGo</h1>
|
||||||
|
<p align="center">
|
||||||
|
A Go rewrite of <a href="https://github.com/ShukeBta/MediaStation">MediaStation</a> — your private home media center.
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md">中文</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go" alt="Go">
|
||||||
|
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||||
|
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
||||||
|
<img src="https://img.shields.io/badge/SQLite-WAL-003B57?style=flat-square&logo=sqlite" alt="SQLite">
|
||||||
|
<img src="https://img.shields.io/badge/Docker-Alpine_3.19-2496ED?style=flat-square&logo=docker" alt="Docker">
|
||||||
|
<img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why a rewrite?
|
||||||
|
|
||||||
|
The original MediaStation is a Python/FastAPI + Vue project. **MediaStationGo** is a from-scratch reimplementation that adopts a lighter, single-binary deployment model:
|
||||||
|
|
||||||
|
- **Backend**: Go 1.25 + Gin + GORM + SQLite (WAL mode).
|
||||||
|
- **Frontend**: React 18 + Vite + Tailwind CSS + Zustand.
|
||||||
|
- **Distribution**: ~30 MB static binary (CGO disabled), or a multi-arch Alpine Docker image.
|
||||||
|
|
||||||
|
The goal is to keep the user-facing feature surface familiar (libraries, scanning, scraping, direct play/HLS, multi-user, downloads, RSS) while making deployment painless on NAS hardware.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Authentication & Users
|
||||||
|
- ✅ JWT auth with admin/user roles
|
||||||
|
- ✅ First-run admin seeding (`admin / admin123`, override via `ADMIN_INITIAL_PASSWORD`)
|
||||||
|
- ✅ Profile page (email / avatar / change password)
|
||||||
|
- ✅ Admin user table with role promotion / demotion
|
||||||
|
- ✅ Audit log for sensitive actions (login, library CRUD, downloads, etc.)
|
||||||
|
|
||||||
|
### Library Management
|
||||||
|
- ✅ Library CRUD + recursive filesystem scan
|
||||||
|
- ✅ ffprobe metadata extraction (duration / resolution / codecs / container)
|
||||||
|
- ✅ Smart filename cleaning with year + season/episode parsing
|
||||||
|
- ✅ Multi-provider scrape chain by library type:
|
||||||
|
- movie → TMDb (with optional Fanart.tv high-res poster upgrade)
|
||||||
|
- tv → TheTVDB (fallback TMDb)
|
||||||
|
- anime → Bangumi (fallback TMDb)
|
||||||
|
- ✅ Image proxy with disk cache (TMDb / Bangumi / Douban / Fanart / TheTVDB)
|
||||||
|
- ✅ TV / anime libraries grouped by season with episode listing
|
||||||
|
- ✅ fsnotify-based filesystem watcher with 5 s coalescing debouncer
|
||||||
|
|
||||||
|
### Playback
|
||||||
|
- ✅ Direct-play streaming with HTTP `Range` support
|
||||||
|
- ✅ HLS on-demand transcoding (single ffmpeg job per media)
|
||||||
|
- ✅ External subtitle discovery (.srt / .vtt / .ass / .ssa) with on-the-fly WebVTT conversion
|
||||||
|
- ✅ Resume position written every 10 s + Continue Watching row on home
|
||||||
|
- ✅ Favourites (toggle) + ordered Playlists (CRUD)
|
||||||
|
|
||||||
|
### PT Site Management
|
||||||
|
- ✅ Site configuration CRUD
|
||||||
|
- ✅ 6 PT site types: nexusphp / gazelle / unit3d / mteam / discuz / custom_rss
|
||||||
|
- ✅ 3 auth methods: Cookie / API Key / Auth Header
|
||||||
|
- ✅ Site connection testing
|
||||||
|
- ✅ Cross-site torrent search
|
||||||
|
- ✅ Extended config via Extra JSON (User-Agent / RSS URL / timeout / priority / proxy / downloader)
|
||||||
|
|
||||||
|
### Automation
|
||||||
|
- ✅ qBittorrent download integration (add / list / delete via Web UI API)
|
||||||
|
- ✅ RSS subscriptions with regex filters, GUID dedup and 10-minute polling
|
||||||
|
- ✅ Automatic media file organization (move / copy / hardlink / symlink)
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
- ✅ Real-time scan / scrape / transcode / download / subscription events over WebSocket
|
||||||
|
- ✅ Dashboard at `/stats` (CPU / memory / disk / library counts / Goroutines)
|
||||||
|
- ✅ Real-time tasks panel at `/tasks` (active ffmpeg jobs + qBittorrent torrents)
|
||||||
|
- ✅ NFO export (Kodi / Jellyfin compatibility) — single media or whole library
|
||||||
|
- ✅ Hardware-accel encoder profiles: Software / NVENC / Intel QSV / VAAPI
|
||||||
|
- ✅ Single-binary build, multi-arch Docker image, GitHub Actions CI + GHCR publish
|
||||||
|
|
||||||
|
### Discovery & AI
|
||||||
|
- ✅ TMDb Discover — trending + popular rails on homepage
|
||||||
|
- ✅ AI smart search (OpenAI-compatible) — natural-language queries → structured intent
|
||||||
|
- ✅ AI recommendations seeded from your watch history (`GET /api/ai/recommend`)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- ✅ React SPA with code-splitting: Login / Home / Library / Search / Favourites / Playlists /
|
||||||
|
Media detail / Player (HLS + direct + subtitles) / Profile / Downloads / Subscriptions /
|
||||||
|
Stats / Admin / Site Management / API Config
|
||||||
|
- ✅ Global toast notifications driven by the WebSocket hub
|
||||||
|
- ✅ Initial bundle ~250 KB / 83 KB gzipped (hls.js loaded only on first HLS playback)
|
||||||
|
|
||||||
|
### Roadmap
|
||||||
|
|
||||||
|
| Feature | Status |
|
||||||
|
|---------|--------|
|
||||||
|
| Bidirectional Jellyfin / Emby compatibility layer | ⏳ |
|
||||||
|
| DLNA / Chromecast | ⏳ |
|
||||||
|
| Online subtitle search providers | ⏳ |
|
||||||
|
| Multi-bitrate ABR transcode profiles | ⏳ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/ShukeBta/MediaStationGo.git
|
||||||
|
cd MediaStationGo
|
||||||
|
|
||||||
|
# (optional) edit docker-compose.yml to mount your media root at /media
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:8080> and log in with `admin / admin123`.
|
||||||
|
|
||||||
|
### Bare Metal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# requirements: Go 1.25+, Node 20+, ffmpeg
|
||||||
|
make build # produces bin/mediastation-go and web/dist
|
||||||
|
./bin/mediastation-go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev # backend on :8080, MEDIASTATION_APP_DEBUG=true
|
||||||
|
make dev-web # vite dev server on :3000, proxies /api -> :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration is layered — defaults < `config.yaml` < `config/*.yaml` < environment variables prefixed with `MEDIASTATION_`.
|
||||||
|
|
||||||
|
### Most-Used Keys
|
||||||
|
|
||||||
|
| Key | Default | Purpose |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `MEDIASTATION_APP_PORT` | `8080` | HTTP listen port |
|
||||||
|
| `MEDIASTATION_APP_DATA_DIR` | `./data` | DB / cache / JWT secret root |
|
||||||
|
| `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | SPA bundle to serve |
|
||||||
|
| `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite file |
|
||||||
|
| `MEDIASTATION_SECRETS_JWT_SECRET` | *(auto)* | JWT signing key |
|
||||||
|
| `MEDIASTATION_SECRETS_TMDB_API_KEY` | *(empty)* | Enables movie scraping |
|
||||||
|
| `MEDIASTATION_SECRETS_BANGUMI_ACCESS_TOKEN` | *(empty)* | Optional, raises Bangumi rate limit |
|
||||||
|
| `MEDIASTATION_APP_CORS_ORIGINS` | *(empty)* | Allow-list, JSON array |
|
||||||
|
| `ADMIN_INITIAL_PASSWORD` | `admin123` | Bootstrap admin password |
|
||||||
|
|
||||||
|
### Runtime Settings (Admin → Settings)
|
||||||
|
|
||||||
|
These live in the `settings` table and can be edited from the admin UI:
|
||||||
|
|
||||||
|
| Key | Purpose |
|
||||||
|
|-----|---------|
|
||||||
|
| `qbittorrent.url` | qBittorrent Web UI base URL |
|
||||||
|
| `qbittorrent.username` | qBittorrent user |
|
||||||
|
| `qbittorrent.password` | qBittorrent password |
|
||||||
|
| `qbittorrent.savepath` | Optional default save path for new torrents |
|
||||||
|
|
||||||
|
After editing, hit **Downloads → Reload Config** (or `POST /api/downloads/reload`) so the qBittorrent client picks up the new credentials.
|
||||||
|
|
||||||
|
See [`config.example.yaml`](config.example.yaml) for the full surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
MediaStationGo/
|
||||||
|
├── cmd/server/main.go Application entry point
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ Viper-based config loader
|
||||||
|
│ ├── database/ GORM + SQLite (WAL) bootstrap
|
||||||
|
│ ├── model/ GORM data models + AutoMigrate registry
|
||||||
|
│ ├── repository/ Thin data-access layer
|
||||||
|
│ ├── service/ Business logic
|
||||||
|
│ │ ├── auth.go login / register / JWT / seed admin
|
||||||
|
│ │ ├── media.go library + media CRUD
|
||||||
|
│ │ ├── scanner.go fs walker + ffprobe + scrape kick
|
||||||
|
│ │ ├── ffprobe.go ffprobe wrapper
|
||||||
|
│ │ ├── tmdb.go TMDb provider
|
||||||
|
│ │ ├── bangumi.go Bangumi provider
|
||||||
|
│ │ ├── scraper.go orchestrator + filename cleaner
|
||||||
|
│ │ ├── site.go PT site CRUD + connection test + cross-site search
|
||||||
|
│ │ ├── site_adapter.go 6 PT site type adapters
|
||||||
|
│ │ ├── stream.go direct play + HLS playlist / segment
|
||||||
|
│ │ ├── transcoder.go per-media ffmpeg HLS job manager
|
||||||
|
│ │ ├── subtitle.go external subtitle discovery + .vtt conversion
|
||||||
|
│ │ ├── image_proxy.go cached, allow-listed image proxy
|
||||||
|
│ │ ├── playback.go history / favourites / playlists
|
||||||
|
│ │ ├── watcher.go fsnotify debouncer
|
||||||
|
│ │ ├── qbittorrent.go qBittorrent v2 API client
|
||||||
|
│ │ ├── downloads.go download orchestrator + WS poller
|
||||||
|
│ │ ├── subscription.go RSS poller
|
||||||
|
│ │ ├── stats.go dashboard snapshot
|
||||||
|
│ │ ├── profile.go non-credential user mutations
|
||||||
|
│ │ ├── audit.go audit log writer
|
||||||
|
│ │ ├── organizer.go media file organizer
|
||||||
|
│ │ ├── ws_hub.go pub/sub broker for the WS
|
||||||
|
│ │ └── walk.go / episode_parser.go helpers
|
||||||
|
│ ├── middleware/ Gin middleware (CORS / JWT / admin)
|
||||||
|
│ └── handler/ HTTP route definitions (one file per concern)
|
||||||
|
├── web/ React 18 + Vite SPA
|
||||||
|
│ ├── src/api/ axios helpers (one per service)
|
||||||
|
│ ├── src/components/ Layout, MediaCard, GlobalEvents, RequireAuth, APIConfigsPanel
|
||||||
|
│ ├── src/hooks/ useWebSocket, …
|
||||||
|
│ ├── src/pages/ Home / Library / Search / Player / Downloads / Admin / Sites
|
||||||
|
│ ├── src/stores/ Zustand (auth)
|
||||||
|
│ └── src/types/ Domain types mirrored from Go
|
||||||
|
├── Dockerfile Multi-stage, multi-arch build
|
||||||
|
├── docker-compose.yml NAS-friendly deployment
|
||||||
|
├── Makefile build / dev / docker / test
|
||||||
|
├── config.example.yaml Full configuration template
|
||||||
|
└── .github/workflows/ CI + GHCR publish
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Released under the [GNU GPL v3.0](LICENSE).
|
||||||
@@ -265,12 +265,6 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
|||||||
authed.GET("/download/tasks", downloadTasksAliasHandler(svc))
|
authed.GET("/download/tasks", downloadTasksAliasHandler(svc))
|
||||||
authed.POST("/download/add", addDownloadHandler(svc))
|
authed.POST("/download/add", addDownloadHandler(svc))
|
||||||
|
|
||||||
// ── License (anyone authenticated can activate / heartbeat) ──
|
|
||||||
authed.POST("/license/activate", licenseActivateHandler(svc))
|
|
||||||
authed.POST("/license/heartbeat", licenseHeartbeatHandler(svc))
|
|
||||||
authed.GET("/license/status", licenseStatusHandler(svc))
|
|
||||||
authed.GET("/license/heartbeat-status", licenseStatusHandler(svc))
|
|
||||||
|
|
||||||
// ── Assistant (multi-turn AI chat) ──
|
// ── Assistant (multi-turn AI chat) ──
|
||||||
authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
|
authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc))
|
||||||
authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc))
|
authed.POST("/admin/assistant/sessions", createAssistantSessionHandler(svc))
|
||||||
@@ -312,13 +306,6 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
|||||||
admin.POST("/download/clients/:id/test", testDownloadClientHandler(svc))
|
admin.POST("/download/clients/:id/test", testDownloadClientHandler(svc))
|
||||||
admin.GET("/download/aria2/stats", aria2StatsHandler(svc))
|
admin.GET("/download/aria2/stats", aria2StatsHandler(svc))
|
||||||
|
|
||||||
// License generation / revocation.
|
|
||||||
admin.POST("/license/generate", licenseGenerateHandler(svc))
|
|
||||||
admin.GET("/license/list", licenseListHandler(svc))
|
|
||||||
admin.GET("/license/:id/activations", licenseListActivationsHandler(svc))
|
|
||||||
admin.POST("/license/activation/:id/unbind", licenseUnbindHandler(svc))
|
|
||||||
admin.POST("/license/:id/revoke", licenseRevokeHandler(svc))
|
|
||||||
|
|
||||||
// System scheduler trigger alias.
|
// System scheduler trigger alias.
|
||||||
admin.POST("/system/scheduler/:name/trigger", schedulerTriggerHandler(svc))
|
admin.POST("/system/scheduler/:name/trigger", schedulerTriggerHandler(svc))
|
||||||
|
|
||||||
@@ -352,10 +339,6 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
|||||||
admin.GET("/scheduler", schedulerStatusHandler(svc))
|
admin.GET("/scheduler", schedulerStatusHandler(svc))
|
||||||
admin.POST("/scheduler/:name/run", schedulerRunHandler(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).
|
// API Config management (admin only).
|
||||||
@@ -396,38 +379,11 @@ func versionInfo(c *gin.Context) {
|
|||||||
|
|
||||||
// ─── 权限 Handler 包装 ────────────────────────────────────────────────────────
|
// ─── 权限 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 {
|
func getMyPermissionsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
h := NewPermissionHandler(svc, svc.Log)
|
h := NewPermissionHandler(svc, svc.Log)
|
||||||
return h.GetMyPermissions
|
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 包装 ───────────────────────────────────────────────────
|
// ─── API Config Handler 包装 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
func listApiConfigsHandler(svc *service.Container) gin.HandlerFunc {
|
func listApiConfigsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
@@ -467,73 +423,23 @@ func testApiConfigHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
|
|
||||||
// ─── Download Client Handler 包装 ─────────────────────────────────────────────
|
// ─── 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 {
|
func getDownloadClientHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
h := NewDownloadClientHandler(svc, svc.Log)
|
h := NewDownloadClientHandler(svc, svc.Log)
|
||||||
return h.Get
|
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 包装 ──────────────────────────────────────────────
|
// ─── 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 {
|
func getNotifyChannelTypesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
h := NewNotifyHandler(svc, svc.Log)
|
h := NewNotifyHandler(svc, svc.Log)
|
||||||
return h.GetTypes
|
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 {
|
func getNotifyChannelHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
h := NewNotifyHandler(svc, svc.Log)
|
h := NewNotifyHandler(svc, svc.Log)
|
||||||
return h.Get
|
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 包装 ──────────────────────────────────────────────────
|
// ─── Scheduler Handler 包装 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
func schedulerListTasksHandler(svc *service.Container) gin.HandlerFunc {
|
func schedulerListTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
// Package handler — license key endpoints.
|
|
||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
type generateKeyReq struct {
|
|
||||||
Customer string `json:"customer"`
|
|
||||||
Plan string `json:"plan"`
|
|
||||||
MaxActivations int `json:"max_activations"`
|
|
||||||
ExpiresAt string `json:"expires_at,omitempty"` // RFC3339, "" = perpetual
|
|
||||||
Notes string `json:"notes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func licenseGenerateHandler(svc *service.Container) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
var req generateKeyReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var expires *time.Time
|
|
||||||
if req.ExpiresAt != "" {
|
|
||||||
t, err := time.Parse(time.RFC3339, req.ExpiresAt)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_at must be RFC3339"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expires = &t
|
|
||||||
}
|
|
||||||
k, err := svc.License.Generate(
|
|
||||||
c.Request.Context(),
|
|
||||||
req.Customer, req.Plan, req.Notes,
|
|
||||||
req.MaxActivations, expires,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func licenseListHandler(svc *service.Container) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
rows, err := svc.License.List(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, rows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type activateReq struct {
|
|
||||||
Key string `json:"key" binding:"required"`
|
|
||||||
DeviceID string `json:"device_id" binding:"required"`
|
|
||||||
DeviceName string `json:"device_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func licenseActivateHandler(svc *service.Container) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
var req activateReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a, err := svc.License.Activate(
|
|
||||||
c.Request.Context(), req.Key, req.DeviceID, req.DeviceName, c.ClientIP(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func licenseListActivationsHandler(svc *service.Container) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
rows, err := svc.License.ListActivations(c.Request.Context(), c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, rows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func licenseUnbindHandler(svc *service.Container) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if err := svc.License.Unbind(c.Request.Context(), c.Param("id")); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func licenseRevokeHandler(svc *service.Container) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if err := svc.License.Revoke(c.Request.Context(), c.Param("id")); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
actID := c.Query("activation_id")
|
|
||||||
if actID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "activation_id required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svc.License.Heartbeat(c.Request.Context(), actID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func licenseStatusHandler(svc *service.Container) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
keyID := c.Query("key_id")
|
|
||||||
if keyID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "key_id required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
out, err := svc.License.Status(c.Request.Context(), keyID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ func (h *PermissionHandler) GetUserPermissions(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
perms, err := h.svc.Permission.GetByUserID(c.Request.Context(), userID)
|
perms, err := h.svc.Permissions.Effective(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("get user permissions failed", zap.Error(err))
|
h.log.Error("get user permissions failed", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||||
@@ -64,15 +65,13 @@ func (h *PermissionHandler) UpdateUserPermissions(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req model.UserPermission
|
||||||
Permissions map[string]bool `json:"permissions"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "invalid request", "data": nil})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "invalid request", "data": nil})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.Permission.Update(c.Request.Context(), userID, req.Permissions); err != nil {
|
if err := h.svc.Permissions.Save(c.Request.Context(), userID, &req); err != nil {
|
||||||
h.log.Error("update user permissions failed", zap.Error(err), zap.String("user_id", userID))
|
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})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||||
return
|
return
|
||||||
@@ -97,7 +96,7 @@ func (h *PermissionHandler) ResetUserPermissions(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.Permission.ResetToDefault(c.Request.Context(), userID); err != nil {
|
if _, err := h.svc.Permissions.Reset(c.Request.Context(), userID); err != nil {
|
||||||
h.log.Error("reset user permissions failed", zap.Error(err), zap.String("user_id", userID))
|
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})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||||
return
|
return
|
||||||
@@ -115,7 +114,7 @@ func (h *PermissionHandler) GetMyPermissions(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
perms, err := h.svc.Permission.GetPermissionMap(c.Request.Context(), currentUserID)
|
perms, err := h.svc.Permissions.Effective(c.Request.Context(), currentUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("get my permissions failed", zap.Error(err))
|
h.log.Error("get my permissions failed", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
|
||||||
@@ -127,7 +126,7 @@ func (h *PermissionHandler) GetMyPermissions(c *gin.Context) {
|
|||||||
tier := middleware.GetUserTier(c)
|
tier := middleware.GetUserTier(c)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "ok",
|
"message": "ok",
|
||||||
"data": gin.H{
|
"data": gin.H{
|
||||||
"permissions": perms,
|
"permissions": perms,
|
||||||
|
|||||||
@@ -33,17 +33,17 @@ func (h *SiteHandler) ListSites(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": sites})
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": sites})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSite 获取单个站点详情(解密敏感字段)。
|
// GetSite 获取单个站点详情。
|
||||||
func (h *SiteHandler) GetSite(c *gin.Context) {
|
func (h *SiteHandler) GetSite(c *gin.Context) {
|
||||||
site, err := h.svc.Site.GetByID(c.Request.Context(), c.Param("id"))
|
site, err := h.svc.Site.FindByID(c.Request.Context(), c.Param("id"))
|
||||||
if err != nil {
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if site == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": 1, "message": "site not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": site})
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": site})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,41 +54,30 @@ func (h *SiteHandler) CreateSite(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
created, err := h.svc.Site.Create(c.Request.Context(), &site)
|
if err := h.svc.Site.Create(c.Request.Context(), &site); err != nil {
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, gin.H{"code": 0, "message": "ok", "data": created})
|
c.JSON(http.StatusCreated, gin.H{"code": 0, "message": "ok", "data": site})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSite 更新站点。
|
// UpdateSite 更新站点。
|
||||||
func (h *SiteHandler) UpdateSite(c *gin.Context) {
|
func (h *SiteHandler) UpdateSite(c *gin.Context) {
|
||||||
var site model.Site
|
patch := make(map[string]any)
|
||||||
if err := c.ShouldBindJSON(&site); err != nil {
|
if err := c.ShouldBindJSON(&patch); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
site.ID = c.Param("id")
|
if err := h.svc.Site.Update(c.Request.Context(), c.Param("id"), patch); err != nil {
|
||||||
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()})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": updated})
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteSite 删除站点。
|
// DeleteSite 删除站点。
|
||||||
func (h *SiteHandler) DeleteSite(c *gin.Context) {
|
func (h *SiteHandler) DeleteSite(c *gin.Context) {
|
||||||
if err := h.svc.Site.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,11 +86,16 @@ func (h *SiteHandler) DeleteSite(c *gin.Context) {
|
|||||||
|
|
||||||
// TestSite 测试站点连通性。
|
// TestSite 测试站点连通性。
|
||||||
func (h *SiteHandler) TestSite(c *gin.Context) {
|
func (h *SiteHandler) TestSite(c *gin.Context) {
|
||||||
if err := h.svc.Site.Authenticate(c.Request.Context(), c.Param("id")); err != nil {
|
ok, msg, err := h.svc.Site.TestConnection(c.Request.Context(), c.Param("id"))
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()})
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok"})
|
if !ok {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSiteTypes 返回支持的站点类型列表。
|
// GetSiteTypes 返回支持的站点类型列表。
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -66,21 +67,36 @@ func createSiteHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
enabled = *req.Enabled
|
enabled = *req.Enabled
|
||||||
}
|
}
|
||||||
|
// Pack fields not in the core model into Extra JSON.
|
||||||
|
extraMap := map[string]any{}
|
||||||
|
if req.UserAgent != "" {
|
||||||
|
extraMap["user_agent"] = req.UserAgent
|
||||||
|
}
|
||||||
|
if req.RSSURL != "" {
|
||||||
|
extraMap["rss_url"] = req.RSSURL
|
||||||
|
}
|
||||||
|
if req.Timeout > 0 {
|
||||||
|
extraMap["timeout"] = req.Timeout
|
||||||
|
}
|
||||||
|
if req.Priority > 0 {
|
||||||
|
extraMap["priority"] = req.Priority
|
||||||
|
}
|
||||||
|
extraMap["use_proxy"] = req.UseProxy
|
||||||
|
if req.Downloader != "" {
|
||||||
|
extraMap["downloader"] = req.Downloader
|
||||||
|
}
|
||||||
|
extraJSON, _ := json.Marshal(extraMap)
|
||||||
|
|
||||||
site := &model.Site{
|
site := &model.Site{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
BaseURL: req.BaseURL,
|
URL: req.BaseURL,
|
||||||
SiteType: req.SiteType,
|
Type: req.SiteType,
|
||||||
AuthType: req.AuthType,
|
AuthType: req.AuthType,
|
||||||
Cookie: req.Cookie,
|
Cookie: req.Cookie,
|
||||||
APIKey: req.APIKey,
|
APIKey: req.APIKey,
|
||||||
AuthHeader: req.AuthHeader,
|
AuthHeader: req.AuthHeader,
|
||||||
UserAgent: req.UserAgent,
|
Extra: string(extraJSON),
|
||||||
RSSURL: req.RSSURL,
|
|
||||||
Timeout: req.Timeout,
|
|
||||||
Priority: req.Priority,
|
|
||||||
UseProxy: req.UseProxy,
|
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
Downloader: req.Downloader,
|
|
||||||
}
|
}
|
||||||
if err := svc.Site.Create(c.Request.Context(), site); err != nil {
|
if err := svc.Site.Create(c.Request.Context(), site); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -50,11 +50,17 @@ func siteUserdataHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "site not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "site not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
loginStatus := "unknown"
|
||||||
|
if s.LastError == "ok" {
|
||||||
|
loginStatus = "ok"
|
||||||
|
} else if s.LastError != "" {
|
||||||
|
loginStatus = "fail"
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"site_id": s.ID,
|
"site_id": s.ID,
|
||||||
"name": s.Name,
|
"name": s.Name,
|
||||||
"cookie_set": len(s.Cookie) > 0,
|
"cookie_set": len(s.Cookie) > 0,
|
||||||
"login_status": s.LoginStatus,
|
"login_status": loginStatus,
|
||||||
"note": "userdata parsing not implemented; stub",
|
"note": "userdata parsing not implemented; stub",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,51 +204,6 @@ type AccessLog struct {
|
|||||||
Detail string `gorm:"type:text" json:"detail"`
|
Detail string `gorm:"type:text" json:"detail"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Site stores a PT/BT tracker site configuration used by the subscription
|
|
||||||
// and cross-site search system. Mirrors the original MediaStation sites table.
|
|
||||||
//
|
|
||||||
// Supported site types: nexusphp / gazelle / unit3d / mteam / custom_rss
|
|
||||||
// Supported auth types: cookie / api_key / authorization
|
|
||||||
type Site struct {
|
|
||||||
Base
|
|
||||||
Name string `gorm:"size:128;not null" json:"name"`
|
|
||||||
BaseURL string `gorm:"size:512;not null" json:"base_url"`
|
|
||||||
SiteType string `gorm:"size:32;default:nexusphp" json:"site_type"`
|
|
||||||
AuthType string `gorm:"size:32;default:cookie" json:"auth_type"`
|
|
||||||
Cookie string `gorm:"type:text" json:"cookie,omitempty"`
|
|
||||||
APIKey string `gorm:"size:512" json:"api_key,omitempty"`
|
|
||||||
AuthHeader string `gorm:"size:512" json:"auth_header,omitempty"`
|
|
||||||
UserAgent string `gorm:"size:512" json:"user_agent,omitempty"`
|
|
||||||
RSSURL string `gorm:"size:1024" json:"rss_url,omitempty"`
|
|
||||||
Timeout int `gorm:"default:15" json:"timeout"`
|
|
||||||
Priority int `gorm:"default:50" json:"priority"`
|
|
||||||
UseProxy bool `gorm:"default:false" json:"use_proxy"`
|
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
|
||||||
LoginStatus string `gorm:"size:20;default:unknown" json:"login_status"`
|
|
||||||
Downloader string `gorm:"size:50" json:"downloader,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotifyChannel is one named outbound notification destination.
|
|
||||||
//
|
|
||||||
// The Config column holds a JSON blob whose schema depends on the
|
|
||||||
// ChannelType (telegram/wechat/bark/webhook):
|
|
||||||
//
|
|
||||||
// telegram → {bot_token, chat_id}
|
|
||||||
// wechat → {sendkey}
|
|
||||||
// bark → {device_key, server?}
|
|
||||||
// webhook → {url, method, headers (JSON string), body_template}
|
|
||||||
//
|
|
||||||
// The Events column is a JSON array of event-type strings the channel
|
|
||||||
// subscribes to; an empty array means "all events".
|
|
||||||
type NotifyChannel struct {
|
|
||||||
Base
|
|
||||||
Name string `gorm:"size:128;not null" json:"name"`
|
|
||||||
ChannelType string `gorm:"size:32;not null" json:"channel_type"`
|
|
||||||
Config string `gorm:"type:text;not null" json:"config"`
|
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
|
||||||
Events string `gorm:"type:text;default:'[]'" json:"events"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlayProfile lets one user define multiple "viewing personas" with
|
// PlayProfile lets one user define multiple "viewing personas" with
|
||||||
// different content-rating limits, library access, and player defaults.
|
// different content-rating limits, library access, and player defaults.
|
||||||
// The original Vue project sketched this out as a forward-looking
|
// The original Vue project sketched this out as a forward-looking
|
||||||
@@ -274,28 +229,6 @@ type PlayProfile struct {
|
|||||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserPermission stores per-user feature toggles for the React UI's
|
|
||||||
// menu visibility + route guards. The original Python project surfaces
|
|
||||||
// 11 boolean flags; we mirror the same set so the existing frontend
|
|
||||||
// can swap to the Go API without code changes.
|
|
||||||
type UserPermission struct {
|
|
||||||
UserID string `gorm:"primaryKey;size:36" json:"user_id"`
|
|
||||||
CanPlayMedia bool `gorm:"default:true" json:"can_play_media"`
|
|
||||||
CanFavorite bool `gorm:"default:true" json:"can_favorite"`
|
|
||||||
CanViewHistory bool `gorm:"default:true" json:"can_view_history"`
|
|
||||||
CanViewDashboard bool `gorm:"default:true" json:"can_view_dashboard"`
|
|
||||||
CanViewDiscover bool `gorm:"default:true" json:"can_view_discover"`
|
|
||||||
CanManageDownloads bool `gorm:"default:false" json:"can_manage_downloads"`
|
|
||||||
CanManageSubscriptions bool `gorm:"default:false" json:"can_manage_subscriptions"`
|
|
||||||
CanManageSites bool `gorm:"default:false" json:"can_manage_sites"`
|
|
||||||
CanManageFiles bool `gorm:"default:false" json:"can_manage_files"`
|
|
||||||
CanManageSTRM bool `gorm:"default:false" json:"can_manage_strm"`
|
|
||||||
CanCast bool `gorm:"default:true" json:"can_cast"`
|
|
||||||
CanUseAIAssistant bool `gorm:"default:false" json:"can_use_ai_assistant"`
|
|
||||||
CanAccessSettings bool `gorm:"default:false" json:"can_access_settings"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StorageConfig holds the connection settings for one external storage
|
// StorageConfig holds the connection settings for one external storage
|
||||||
// backend (Alist / S3 / WebDAV). Type column makes the row poly-typed
|
// backend (Alist / S3 / WebDAV). Type column makes the row poly-typed
|
||||||
// — Config is a JSON blob whose shape is determined by Type.
|
// — Config is a JSON blob whose shape is determined by Type.
|
||||||
@@ -311,47 +244,6 @@ type StorageConfig struct {
|
|||||||
LastError string `gorm:"size:512" json:"last_error,omitempty"`
|
LastError string `gorm:"size:512" json:"last_error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LicenseKey is one issued license for a customer. Activations live in
|
|
||||||
// a child table so a single key can bind to multiple devices when its
|
|
||||||
// MaxActivations > 1.
|
|
||||||
type LicenseKey struct {
|
|
||||||
Base
|
|
||||||
Key string `gorm:"uniqueIndex;size:64;not null" json:"key"`
|
|
||||||
Customer string `gorm:"size:128" json:"customer,omitempty"`
|
|
||||||
Plan string `gorm:"size:32;default:basic" json:"plan"`
|
|
||||||
MaxActivations int `gorm:"default:1" json:"max_activations"`
|
|
||||||
IssuedAt time.Time `json:"issued_at"`
|
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
||||||
Revoked bool `gorm:"default:false" json:"revoked"`
|
|
||||||
Notes string `gorm:"type:text" json:"notes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LicenseActivation is one (key, device) binding.
|
|
||||||
type LicenseActivation struct {
|
|
||||||
Base
|
|
||||||
KeyID string `gorm:"index;size:36;not null" json:"key_id"`
|
|
||||||
DeviceID string `gorm:"size:128;not null" json:"device_id"`
|
|
||||||
DeviceName string `gorm:"size:128" json:"device_name,omitempty"`
|
|
||||||
IP string `gorm:"size:64" json:"ip,omitempty"`
|
|
||||||
UnboundAt *time.Time `json:"unbound_at,omitempty"`
|
|
||||||
HeartbeatAt *time.Time `json:"heartbeat_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DownloadClient is one configured downloader (qBittorrent / Aria2 /
|
|
||||||
// Transmission). We keep the password column out of JSON so list calls
|
|
||||||
// don't leak secrets to the React UI.
|
|
||||||
type DownloadClient struct {
|
|
||||||
Base
|
|
||||||
Name string `gorm:"size:128;not null" json:"name"`
|
|
||||||
Type string `gorm:"size:16;not null" json:"type"` // qbittorrent / transmission / aria2
|
|
||||||
URL string `gorm:"size:512;not null" json:"url"`
|
|
||||||
Username string `gorm:"size:128" json:"username,omitempty"`
|
|
||||||
Password string `gorm:"size:512" json:"-"`
|
|
||||||
SavePath string `gorm:"size:1024" json:"save_path,omitempty"`
|
|
||||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssistantSession groups a multi-turn chat with the AI assistant.
|
// AssistantSession groups a multi-turn chat with the AI assistant.
|
||||||
type AssistantSession struct {
|
type AssistantSession struct {
|
||||||
Base
|
Base
|
||||||
@@ -397,8 +289,6 @@ func AllModels() []interface{} {
|
|||||||
&STRMRecord{},
|
&STRMRecord{},
|
||||||
&PlayProfile{},
|
&PlayProfile{},
|
||||||
&StorageConfig{},
|
&StorageConfig{},
|
||||||
&LicenseKey{},
|
|
||||||
&LicenseActivation{},
|
|
||||||
&AssistantSession{},
|
&AssistantSession{},
|
||||||
&AssistantMessage{},
|
&AssistantMessage{},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssistantRepository persists model.AssistantSession + AssistantMessage records.
|
||||||
|
type AssistantRepository struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
// ─── Session ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// CreateSession inserts a new chat session.
|
||||||
|
func (r *AssistantRepository) CreateSession(ctx context.Context, s *model.AssistantSession) error {
|
||||||
|
return r.db.WithContext(ctx).Create(s).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSession returns a session by ID, or (nil, nil).
|
||||||
|
func (r *AssistantRepository) FindSession(ctx context.Context, id string) (*model.AssistantSession, error) {
|
||||||
|
var s model.AssistantSession
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSessions returns sessions for a user, or all when userID is empty.
|
||||||
|
func (r *AssistantRepository) ListSessions(ctx context.Context, userID string) ([]model.AssistantSession, error) {
|
||||||
|
q := r.db.WithContext(ctx).Model(&model.AssistantSession{})
|
||||||
|
if userID != "" {
|
||||||
|
q = q.Where("user_id = ?", userID)
|
||||||
|
}
|
||||||
|
var rows []model.AssistantSession
|
||||||
|
err := q.Order("created_at desc").Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession soft-deletes a session (cascade handled by GORM hooks if set).
|
||||||
|
func (r *AssistantRepository) DeleteSession(ctx context.Context, id string) error {
|
||||||
|
return r.db.WithContext(ctx).Delete(&model.AssistantSession{}, "id = ?", id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Message ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// AppendMessage inserts a new message into a session.
|
||||||
|
func (r *AssistantRepository) AppendMessage(ctx context.Context, m *model.AssistantMessage) error {
|
||||||
|
return r.db.WithContext(ctx).Create(m).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMessages returns all messages for a session in chronological order.
|
||||||
|
func (r *AssistantRepository) ListMessages(ctx context.Context, sessionID string) ([]model.AssistantMessage, error) {
|
||||||
|
var rows []model.AssistantMessage
|
||||||
|
err := r.db.WithContext(ctx).Where("session_id = ?", sessionID).
|
||||||
|
Order("created_at asc").Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlayProfileRepository persists model.PlayProfile records.
|
||||||
|
type PlayProfileRepository struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
// Create inserts a new play profile.
|
||||||
|
func (r *PlayProfileRepository) Create(ctx context.Context, p *model.PlayProfile) error {
|
||||||
|
return r.db.WithContext(ctx).Create(p).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID returns the profile or (nil, nil).
|
||||||
|
func (r *PlayProfileRepository) FindByID(ctx context.Context, id string) (*model.PlayProfile, error) {
|
||||||
|
var p model.PlayProfile
|
||||||
|
err := r.db.WithContext(ctx).Where("id = ?", id).First(&p).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns every profile (admin view).
|
||||||
|
func (r *PlayProfileRepository) List(ctx context.Context) ([]model.PlayProfile, error) {
|
||||||
|
var rows []model.PlayProfile
|
||||||
|
err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser returns profiles owned by a user.
|
||||||
|
func (r *PlayProfileRepository) ListByUser(ctx context.Context, userID string) ([]model.PlayProfile, error) {
|
||||||
|
var rows []model.PlayProfile
|
||||||
|
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
|
||||||
|
Order("created_at desc").Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update applies a partial update to a profile row.
|
||||||
|
func (r *PlayProfileRepository) Update(ctx context.Context, id string, patch map[string]any) error {
|
||||||
|
return r.db.WithContext(ctx).Model(&model.PlayProfile{}).
|
||||||
|
Where("id = ?", id).Updates(patch).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete soft-deletes a profile.
|
||||||
|
func (r *PlayProfileRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
return r.db.WithContext(ctx).Delete(&model.PlayProfile{}, "id = ?", id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearDefaultsFor resets is_default for all of a user's profiles.
|
||||||
|
func (r *PlayProfileRepository) ClearDefaultsFor(ctx context.Context, userID string) error {
|
||||||
|
return r.db.WithContext(ctx).Model(&model.PlayProfile{}).
|
||||||
|
Where("user_id = ?", userID).Update("is_default", false).Error
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ type Container struct {
|
|||||||
NotifyChannel *NotifyChannelRepository
|
NotifyChannel *NotifyChannelRepository
|
||||||
Site *SiteRepository
|
Site *SiteRepository
|
||||||
STRM *STRMRepository
|
STRM *STRMRepository
|
||||||
|
PlayProfile *PlayProfileRepository
|
||||||
|
StorageConfig *StorageConfigRepository
|
||||||
|
Assistant *AssistantRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 将每个 repository 连接到单个 *gorm.DB。
|
// New 将每个 repository 连接到单个 *gorm.DB。
|
||||||
@@ -62,6 +65,9 @@ func New(db *gorm.DB) *Container {
|
|||||||
NotifyChannel: &NotifyChannelRepository{db: db},
|
NotifyChannel: &NotifyChannelRepository{db: db},
|
||||||
Site: &SiteRepository{db: db},
|
Site: &SiteRepository{db: db},
|
||||||
STRM: &STRMRepository{db: db},
|
STRM: &STRMRepository{db: db},
|
||||||
|
PlayProfile: &PlayProfileRepository{db: db},
|
||||||
|
StorageConfig: &StorageConfigRepository{db: db},
|
||||||
|
Assistant: &AssistantRepository{db: db},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StorageConfigRepository persists model.StorageConfig records.
|
||||||
|
type StorageConfigRepository struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
// Get returns the config row by type, or (nil, nil).
|
||||||
|
func (r *StorageConfigRepository) Get(ctx context.Context, kind string) (*model.StorageConfig, error) {
|
||||||
|
var c model.StorageConfig
|
||||||
|
err := r.db.WithContext(ctx).Where("type = ?", kind).First(&c).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all storage configs.
|
||||||
|
func (r *StorageConfigRepository) List(ctx context.Context) ([]model.StorageConfig, error) {
|
||||||
|
var rows []model.StorageConfig
|
||||||
|
err := r.db.WithContext(ctx).Order("type asc").Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert creates or replaces a storage config keyed by Type.
|
||||||
|
func (r *StorageConfigRepository) Upsert(ctx context.Context, c *model.StorageConfig) error {
|
||||||
|
return r.db.WithContext(ctx).Where("type = ?", c.Type).
|
||||||
|
Assign(*c).FirstOrCreate(c).Error
|
||||||
|
}
|
||||||
@@ -39,10 +39,9 @@ func NewDownloadClientService(log *zap.Logger, repo *repository.Container) *Down
|
|||||||
type DownloadClientInput struct {
|
type DownloadClientInput struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Type string `json:"type" binding:"required"`
|
Type string `json:"type" binding:"required"`
|
||||||
URL string `json:"url" binding:"required"`
|
Host string `json:"host" binding:"required"`
|
||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
SavePath string `json:"save_path,omitempty"`
|
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
@@ -60,10 +59,9 @@ func (s *DownloadClientService) Create(ctx context.Context, in DownloadClientInp
|
|||||||
c := &model.DownloadClient{
|
c := &model.DownloadClient{
|
||||||
Name: strings.TrimSpace(in.Name),
|
Name: strings.TrimSpace(in.Name),
|
||||||
Type: in.Type,
|
Type: in.Type,
|
||||||
URL: strings.TrimSpace(in.URL),
|
Host: strings.TrimSpace(in.Host),
|
||||||
Username: in.Username,
|
Username: in.Username,
|
||||||
Password: in.Password,
|
Password: in.Password,
|
||||||
SavePath: in.SavePath,
|
|
||||||
IsDefault: in.IsDefault,
|
IsDefault: in.IsDefault,
|
||||||
Enabled: in.Enabled,
|
Enabled: in.Enabled,
|
||||||
}
|
}
|
||||||
@@ -81,9 +79,8 @@ func (s *DownloadClientService) Update(ctx context.Context, id string, in Downlo
|
|||||||
patch := map[string]any{
|
patch := map[string]any{
|
||||||
"name": strings.TrimSpace(in.Name),
|
"name": strings.TrimSpace(in.Name),
|
||||||
"type": in.Type,
|
"type": in.Type,
|
||||||
"url": strings.TrimSpace(in.URL),
|
"host": strings.TrimSpace(in.Host),
|
||||||
"username": in.Username,
|
"username": in.Username,
|
||||||
"save_path": in.SavePath,
|
|
||||||
"is_default": in.IsDefault,
|
"is_default": in.IsDefault,
|
||||||
"enabled": in.Enabled,
|
"enabled": in.Enabled,
|
||||||
}
|
}
|
||||||
@@ -91,7 +88,24 @@ func (s *DownloadClientService) Update(ctx context.Context, id string, in Downlo
|
|||||||
if in.Password != "" {
|
if in.Password != "" {
|
||||||
patch["password"] = in.Password
|
patch["password"] = in.Password
|
||||||
}
|
}
|
||||||
if err := s.repo.DownloadClient.Update(ctx, id, patch); err != nil {
|
// Fetch existing row, apply patch via Save
|
||||||
|
existing, err := s.repo.DownloadClient.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
return nil, errors.New("client not found")
|
||||||
|
}
|
||||||
|
existing.Name = patch["name"].(string)
|
||||||
|
existing.Type = patch["type"].(string)
|
||||||
|
existing.Host = patch["host"].(string)
|
||||||
|
existing.Username = patch["username"].(string)
|
||||||
|
existing.IsDefault = patch["is_default"].(bool)
|
||||||
|
existing.Enabled = patch["enabled"].(bool)
|
||||||
|
if pw, ok := patch["password"]; ok {
|
||||||
|
existing.Password = pw.(string)
|
||||||
|
}
|
||||||
|
if err := s.repo.DownloadClient.Update(ctx, existing); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.repo.DownloadClient.FindByID(ctx, id)
|
return s.repo.DownloadClient.FindByID(ctx, id)
|
||||||
@@ -120,7 +134,7 @@ func (s *DownloadClientService) Test(ctx context.Context, id string) error {
|
|||||||
body.Set("password", c.Password)
|
body.Set("password", c.Password)
|
||||||
req, _ := http.NewRequestWithContext(
|
req, _ := http.NewRequestWithContext(
|
||||||
ctx, http.MethodPost,
|
ctx, http.MethodPost,
|
||||||
strings.TrimRight(c.URL, "/")+"/api/v2/auth/login",
|
strings.TrimRight(c.Host, "/")+"/api/v2/auth/login",
|
||||||
strings.NewReader(body.Encode()),
|
strings.NewReader(body.Encode()),
|
||||||
)
|
)
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
@@ -134,7 +148,7 @@ func (s *DownloadClientService) Test(ctx context.Context, id string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case "aria2", "transmission":
|
case "aria2", "transmission":
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.URL, nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.Host, nil)
|
||||||
resp, err := s.client.Do(req)
|
resp, err := s.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -163,7 +177,7 @@ func (s *DownloadClientService) Aria2GlobalStats(ctx context.Context, clientID s
|
|||||||
`{"jsonrpc":"2.0","id":"x","method":"aria2.getGlobalStat","params":["token:%s"]}`,
|
`{"jsonrpc":"2.0","id":"x","method":"aria2.getGlobalStat","params":["token:%s"]}`,
|
||||||
c.Password,
|
c.Password,
|
||||||
)
|
)
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.URL,
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.Host,
|
||||||
strings.NewReader(payload))
|
strings.NewReader(payload))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
resp, err := s.client.Do(req)
|
resp, err := s.client.Do(req)
|
||||||
@@ -183,8 +197,8 @@ func validateClient(in DownloadClientInput) error {
|
|||||||
if strings.TrimSpace(in.Name) == "" {
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
return errors.New("name required")
|
return errors.New("name required")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(in.URL) == "" {
|
if strings.TrimSpace(in.Host) == "" {
|
||||||
return errors.New("url required")
|
return errors.New("host required")
|
||||||
}
|
}
|
||||||
switch in.Type {
|
switch in.Type {
|
||||||
case "qbittorrent", "aria2", "transmission":
|
case "qbittorrent", "aria2", "transmission":
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
// Package service — license key management.
|
|
||||||
//
|
|
||||||
// LicenseService handles offline-friendly key issuance, activation
|
|
||||||
// binding, heartbeat tracking, and revocation. Keys are 24 random
|
|
||||||
// uppercase chars in groups of four (e.g. ABCD-1234-EFGH-5678-IJKL-90MN)
|
|
||||||
// — the same shape the Vue admin UI expects.
|
|
||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LicenseService manages license keys + activations.
|
|
||||||
type LicenseService struct {
|
|
||||||
log *zap.Logger
|
|
||||||
repo *repository.Container
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLicenseService is the constructor.
|
|
||||||
func NewLicenseService(log *zap.Logger, repo *repository.Container) *LicenseService {
|
|
||||||
return &LicenseService{log: log, repo: repo}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate creates a new license key. ExpiresAt nil means "perpetual".
|
|
||||||
func (s *LicenseService) Generate(
|
|
||||||
ctx context.Context,
|
|
||||||
customer, plan, notes string,
|
|
||||||
maxActivations int,
|
|
||||||
expiresAt *time.Time,
|
|
||||||
) (*model.LicenseKey, error) {
|
|
||||||
if maxActivations <= 0 {
|
|
||||||
maxActivations = 1
|
|
||||||
}
|
|
||||||
k := &model.LicenseKey{
|
|
||||||
Key: randomLicenseKey(),
|
|
||||||
Customer: strings.TrimSpace(customer),
|
|
||||||
Plan: strings.TrimSpace(plan),
|
|
||||||
MaxActivations: maxActivations,
|
|
||||||
Notes: strings.TrimSpace(notes),
|
|
||||||
IssuedAt: time.Now(),
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
}
|
|
||||||
if err := s.repo.License.Create(ctx, k); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return k, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns every key (admin view).
|
|
||||||
func (s *LicenseService) List(ctx context.Context) ([]model.LicenseKey, error) {
|
|
||||||
return s.repo.License.List(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Activate binds a key to a device. Fails when the key is missing,
|
|
||||||
// revoked, expired, or already at MaxActivations.
|
|
||||||
func (s *LicenseService) Activate(
|
|
||||||
ctx context.Context,
|
|
||||||
key, deviceID, deviceName, ip string,
|
|
||||||
) (*model.LicenseActivation, error) {
|
|
||||||
k, err := s.repo.License.FindByKey(ctx, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if k == nil {
|
|
||||||
return nil, errors.New("invalid key")
|
|
||||||
}
|
|
||||||
if k.Revoked {
|
|
||||||
return nil, errors.New("key revoked")
|
|
||||||
}
|
|
||||||
if k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()) {
|
|
||||||
return nil, errors.New("key expired")
|
|
||||||
}
|
|
||||||
count, err := s.repo.License.CountActiveActivations(ctx, k.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if int(count) >= k.MaxActivations {
|
|
||||||
return nil, errors.New("activation limit reached")
|
|
||||||
}
|
|
||||||
a := &model.LicenseActivation{
|
|
||||||
KeyID: k.ID,
|
|
||||||
DeviceID: strings.TrimSpace(deviceID),
|
|
||||||
DeviceName: strings.TrimSpace(deviceName),
|
|
||||||
IP: ip,
|
|
||||||
}
|
|
||||||
if err := s.repo.License.AddActivation(ctx, a); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListActivations returns activations for a single key.
|
|
||||||
func (s *LicenseService) ListActivations(ctx context.Context, keyID string) ([]model.LicenseActivation, error) {
|
|
||||||
return s.repo.License.ListActivations(ctx, keyID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unbind marks one activation as released.
|
|
||||||
func (s *LicenseService) Unbind(ctx context.Context, activationID string) error {
|
|
||||||
return s.repo.License.UnbindActivation(ctx, activationID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revoke marks the entire key as revoked.
|
|
||||||
func (s *LicenseService) Revoke(ctx context.Context, keyID string) error {
|
|
||||||
return s.repo.License.Update(ctx, keyID, map[string]any{"revoked": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heartbeat records the last time an activation phoned home.
|
|
||||||
func (s *LicenseService) Heartbeat(ctx context.Context, activationID string) error {
|
|
||||||
return s.repo.License.TouchHeartbeat(ctx, activationID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status returns a summary suitable for the Vue / React status panel.
|
|
||||||
func (s *LicenseService) Status(ctx context.Context, keyID string) (map[string]any, error) {
|
|
||||||
k, err := s.repo.License.FindByID(ctx, keyID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if k == nil {
|
|
||||||
return nil, errors.New("key not found")
|
|
||||||
}
|
|
||||||
count, _ := s.repo.License.CountActiveActivations(ctx, keyID)
|
|
||||||
valid := !k.Revoked
|
|
||||||
if k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()) {
|
|
||||||
valid = false
|
|
||||||
}
|
|
||||||
return map[string]any{
|
|
||||||
"key": k,
|
|
||||||
"active_activations": count,
|
|
||||||
"valid": valid,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// randomLicenseKey produces a 24-char hyphenated key of A-Z and 0-9.
|
|
||||||
func randomLicenseKey() string {
|
|
||||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // omit confusables
|
|
||||||
out := make([]byte, 24)
|
|
||||||
buf := make([]byte, 24)
|
|
||||||
_, _ = rand.Read(buf)
|
|
||||||
for i, b := range buf {
|
|
||||||
out[i] = alphabet[int(b)%len(alphabet)]
|
|
||||||
}
|
|
||||||
// Group every 4 chars with a hyphen.
|
|
||||||
var sb strings.Builder
|
|
||||||
for i, c := range out {
|
|
||||||
if i > 0 && i%4 == 0 {
|
|
||||||
sb.WriteByte('-')
|
|
||||||
}
|
|
||||||
sb.WriteByte(byte(c))
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
@@ -41,11 +41,11 @@ func NewNotifyChannelService(log *zap.Logger, repo *repository.Container) *Notif
|
|||||||
// ChannelInput is the shape accepted by Create / Update. Config is a
|
// ChannelInput is the shape accepted by Create / Update. Config is a
|
||||||
// generic map; it gets serialised to JSON before being persisted.
|
// generic map; it gets serialised to JSON before being persisted.
|
||||||
type ChannelInput struct {
|
type ChannelInput struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
ChannelType string `json:"channel_type" binding:"required"`
|
Type string `json:"type" binding:"required"`
|
||||||
Config map[string]any `json:"config"`
|
Config map[string]any `json:"config"`
|
||||||
Events []string `json:"events"`
|
Events []string `json:"events"`
|
||||||
Enabled *bool `json:"enabled,omitempty"`
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// channelView is the public shape — Config is decoded back to a map so
|
// channelView is the public shape — Config is decoded back to a map so
|
||||||
@@ -96,7 +96,7 @@ func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*ch
|
|||||||
evBlob, _ := json.Marshal(in.Events)
|
evBlob, _ := json.Marshal(in.Events)
|
||||||
n := &model.NotifyChannel{
|
n := &model.NotifyChannel{
|
||||||
Name: strings.TrimSpace(in.Name),
|
Name: strings.TrimSpace(in.Name),
|
||||||
ChannelType: in.ChannelType,
|
Type: in.Type,
|
||||||
Config: string(cfgBlob),
|
Config: string(cfgBlob),
|
||||||
Events: string(evBlob),
|
Events: string(evBlob),
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@@ -119,15 +119,30 @@ func (s *NotifyChannelService) Update(ctx context.Context, id string, in Channel
|
|||||||
cfgBlob, _ := json.Marshal(in.Config)
|
cfgBlob, _ := json.Marshal(in.Config)
|
||||||
evBlob, _ := json.Marshal(in.Events)
|
evBlob, _ := json.Marshal(in.Events)
|
||||||
patch := map[string]any{
|
patch := map[string]any{
|
||||||
"name": strings.TrimSpace(in.Name),
|
"name": strings.TrimSpace(in.Name),
|
||||||
"channel_type": in.ChannelType,
|
"type": in.Type,
|
||||||
"config": string(cfgBlob),
|
"config": string(cfgBlob),
|
||||||
"events": string(evBlob),
|
"events": string(evBlob),
|
||||||
}
|
}
|
||||||
if in.Enabled != nil {
|
if in.Enabled != nil {
|
||||||
patch["enabled"] = *in.Enabled
|
patch["enabled"] = *in.Enabled
|
||||||
}
|
}
|
||||||
if err := s.repo.NotifyChannel.Update(ctx, id, patch); err != nil {
|
// Fetch existing row, apply patch via repo Update
|
||||||
|
existing, err := s.repo.NotifyChannel.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
return nil, errors.New("channel not found")
|
||||||
|
}
|
||||||
|
existing.Name = patch["name"].(string)
|
||||||
|
existing.Type = patch["type"].(string)
|
||||||
|
existing.Config = patch["config"].(string)
|
||||||
|
existing.Events = patch["events"].(string)
|
||||||
|
if en, ok := patch["enabled"]; ok {
|
||||||
|
existing.Enabled = en.(bool)
|
||||||
|
}
|
||||||
|
if err := s.repo.NotifyChannel.Update(ctx, existing); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
row, err := s.repo.NotifyChannel.FindByID(ctx, id)
|
row, err := s.repo.NotifyChannel.FindByID(ctx, id)
|
||||||
@@ -201,7 +216,7 @@ func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyCh
|
|||||||
cfg := map[string]any{}
|
cfg := map[string]any{}
|
||||||
_ = json.Unmarshal([]byte(n.Config), &cfg)
|
_ = json.Unmarshal([]byte(n.Config), &cfg)
|
||||||
|
|
||||||
switch n.ChannelType {
|
switch n.Type {
|
||||||
case "telegram":
|
case "telegram":
|
||||||
token := str(cfg["bot_token"])
|
token := str(cfg["bot_token"])
|
||||||
chat := str(cfg["chat_id"])
|
chat := str(cfg["chat_id"])
|
||||||
@@ -279,7 +294,7 @@ func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyCh
|
|||||||
}
|
}
|
||||||
return s.do(req)
|
return s.do(req)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("unknown channel type %q", n.ChannelType)
|
return fmt.Errorf("unknown channel type %q", n.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *NotifyChannelService) do(req *http.Request) error {
|
func (s *NotifyChannelService) do(req *http.Request) error {
|
||||||
@@ -300,10 +315,10 @@ func validateChannel(in ChannelInput) error {
|
|||||||
if strings.TrimSpace(in.Name) == "" {
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
return errors.New("name required")
|
return errors.New("name required")
|
||||||
}
|
}
|
||||||
switch in.ChannelType {
|
switch in.Type {
|
||||||
case "telegram", "wechat", "bark", "webhook":
|
case "telegram", "wechat", "bark", "webhook":
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported channel type %q", in.ChannelType)
|
return fmt.Errorf("unsupported channel type %q", in.Type)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func DefaultPermissions(userID string) *model.UserPermission {
|
|||||||
CanManageSubscriptions: false,
|
CanManageSubscriptions: false,
|
||||||
CanManageSites: false,
|
CanManageSites: false,
|
||||||
CanManageFiles: false,
|
CanManageFiles: false,
|
||||||
CanManageSTRM: false,
|
CanManageStrm: false,
|
||||||
CanUseAIAssistant: false,
|
CanUseAIAssistant: false,
|
||||||
CanAccessSettings: false,
|
CanAccessSettings: false,
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ func adminGrant(userID string) *model.UserPermission {
|
|||||||
CanManageSubscriptions: true,
|
CanManageSubscriptions: true,
|
||||||
CanManageSites: true,
|
CanManageSites: true,
|
||||||
CanManageFiles: true,
|
CanManageFiles: true,
|
||||||
CanManageSTRM: true,
|
CanManageStrm: true,
|
||||||
CanCast: true,
|
CanCast: true,
|
||||||
CanUseAIAssistant: true,
|
CanUseAIAssistant: true,
|
||||||
CanAccessSettings: true,
|
CanAccessSettings: true,
|
||||||
@@ -79,7 +79,7 @@ func (s *PermissionService) Effective(ctx context.Context, userID string) (*mode
|
|||||||
if u.Role == "admin" {
|
if u.Role == "admin" {
|
||||||
return adminGrant(userID), nil
|
return adminGrant(userID), nil
|
||||||
}
|
}
|
||||||
row, err := s.repo.Permission.Get(ctx, userID)
|
row, err := s.repo.Permission.FindByUserID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ func (s *PermissionService) Effective(ctx context.Context, userID string) (*mode
|
|||||||
// Seed defaults on first read so subsequent updates have a row to
|
// Seed defaults on first read so subsequent updates have a row to
|
||||||
// patch.
|
// patch.
|
||||||
def := DefaultPermissions(userID)
|
def := DefaultPermissions(userID)
|
||||||
if err := s.repo.Permission.Save(ctx, def); err != nil {
|
if err := s.repo.Permission.Upsert(ctx, def); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return def, nil
|
return def, nil
|
||||||
@@ -98,13 +98,30 @@ func (s *PermissionService) Effective(ctx context.Context, userID string) (*mode
|
|||||||
// Save persists the user permission patch (admin only — caller checks).
|
// Save persists the user permission patch (admin only — caller checks).
|
||||||
func (s *PermissionService) Save(ctx context.Context, userID string, in *model.UserPermission) error {
|
func (s *PermissionService) Save(ctx context.Context, userID string, in *model.UserPermission) error {
|
||||||
in.UserID = userID
|
in.UserID = userID
|
||||||
return s.repo.Permission.Save(ctx, in)
|
return s.repo.Permission.Upsert(ctx, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureForUser guarantees a permission row exists for the given user.
|
||||||
|
// If one already exists it is a no-op; otherwise a default row is created.
|
||||||
|
func (s *PermissionService) EnsureForUser(ctx context.Context, userID string) (*model.UserPermission, error) {
|
||||||
|
row, err := s.repo.Permission.FindByUserID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if row != nil {
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
def := DefaultPermissions(userID)
|
||||||
|
if err := s.repo.Permission.Upsert(ctx, def); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return def, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset reverts to the non-admin defaults.
|
// Reset reverts to the non-admin defaults.
|
||||||
func (s *PermissionService) Reset(ctx context.Context, userID string) (*model.UserPermission, error) {
|
func (s *PermissionService) Reset(ctx context.Context, userID string) (*model.UserPermission, error) {
|
||||||
def := DefaultPermissions(userID)
|
def := DefaultPermissions(userID)
|
||||||
if err := s.repo.Permission.Save(ctx, def); err != nil {
|
if err := s.repo.Permission.Upsert(ctx, def); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return def, nil
|
return def, nil
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
// 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"
|
|
||||||
}
|
|
||||||
@@ -57,7 +57,6 @@ type Container struct {
|
|||||||
PlayProfiles *PlayProfileService
|
PlayProfiles *PlayProfileService
|
||||||
Permissions *PermissionService
|
Permissions *PermissionService
|
||||||
StorageCfg *StorageConfigService
|
StorageCfg *StorageConfigService
|
||||||
License *LicenseService
|
|
||||||
DownloadClients *DownloadClientService
|
DownloadClients *DownloadClientService
|
||||||
Assistant *AssistantService
|
Assistant *AssistantService
|
||||||
Organizer *OrganizerService
|
Organizer *OrganizerService
|
||||||
@@ -108,21 +107,18 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
playProfiles := NewPlayProfileService(log, repos)
|
playProfiles := NewPlayProfileService(log, repos)
|
||||||
permissions := NewPermissionService(log, repos)
|
permissions := NewPermissionService(log, repos)
|
||||||
storageCfg := NewStorageConfigService(log, repos, crypto)
|
storageCfg := NewStorageConfigService(log, repos, crypto)
|
||||||
licenseSvc := NewLicenseService(log, repos)
|
|
||||||
downloadClients := NewDownloadClientService(log, repos)
|
downloadClients := NewDownloadClientService(log, repos)
|
||||||
assistant := NewAssistantService(log, repos, ai)
|
assistant := NewAssistantService(log, repos, ai)
|
||||||
organizer := NewOrganizerService(cfg, log, repos)
|
organizer := NewOrganizerService(cfg, log, repos)
|
||||||
douban := NewDoubanProvider(cfg, log)
|
douban := NewDoubanProvider(cfg, log)
|
||||||
siteService := NewSiteService(log, repos)
|
|
||||||
scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir)
|
scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir)
|
||||||
|
|
||||||
// 初始化认证相关服务
|
// 初始化认证相关服务
|
||||||
tokenSvc := NewTokenService(cfg, log, repos)
|
tokenSvc := NewTokenService(cfg, log, repos)
|
||||||
permissionSvc := NewPermissionService(cfg, log, repos)
|
|
||||||
apiConfigSvc := NewApiConfigService(cfg, log, repos, crypto)
|
apiConfigSvc := NewApiConfigService(cfg, log, repos, crypto)
|
||||||
downloadMgr := NewDownloadManager(log, repos, crypto)
|
downloadMgr := NewDownloadManager(log, repos, crypto)
|
||||||
notifySvc := NewNotifyService(log, repos, crypto)
|
notifySvc := NewNotifyService(log, repos, crypto)
|
||||||
siteSvc := NewSiteService(log, repos, crypto)
|
siteSvc := NewSiteService(log, repos)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
@@ -132,7 +128,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
Repo: repos,
|
Repo: repos,
|
||||||
WSHub: hub,
|
WSHub: hub,
|
||||||
SSEHub: sseHub,
|
SSEHub: sseHub,
|
||||||
Auth: NewAuthService(cfg, log, repos, tokenSvc, permissionSvc),
|
Auth: NewAuthService(cfg, log, repos, tokenSvc, permissions),
|
||||||
Media: NewMediaService(cfg, log, repos),
|
Media: NewMediaService(cfg, log, repos),
|
||||||
Scan: scanner,
|
Scan: scanner,
|
||||||
Stream: NewStreamService(cfg, log, repos, transcoder),
|
Stream: NewStreamService(cfg, log, repos, transcoder),
|
||||||
@@ -169,7 +165,6 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
PlayProfiles: playProfiles,
|
PlayProfiles: playProfiles,
|
||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
StorageCfg: storageCfg,
|
StorageCfg: storageCfg,
|
||||||
License: licenseSvc,
|
|
||||||
DownloadClients: downloadClients,
|
DownloadClients: downloadClients,
|
||||||
Assistant: assistant,
|
Assistant: assistant,
|
||||||
Organizer: organizer,
|
Organizer: organizer,
|
||||||
|
|||||||
+27
-22
@@ -32,26 +32,23 @@ func NewSiteService(log *zap.Logger, repo *repository.Container) *SiteService {
|
|||||||
|
|
||||||
// Create persists a new site.
|
// Create persists a new site.
|
||||||
func (s *SiteService) Create(ctx context.Context, site *model.Site) error {
|
func (s *SiteService) Create(ctx context.Context, site *model.Site) error {
|
||||||
if strings.TrimSpace(site.Name) == "" || strings.TrimSpace(site.BaseURL) == "" {
|
if strings.TrimSpace(site.Name) == "" || strings.TrimSpace(site.URL) == "" {
|
||||||
return errors.New("name and base_url required")
|
return errors.New("name and url required")
|
||||||
}
|
}
|
||||||
site.BaseURL = strings.TrimRight(site.BaseURL, "/")
|
site.URL = strings.TrimRight(site.URL, "/")
|
||||||
if site.SiteType == "" {
|
if site.Type == "" {
|
||||||
site.SiteType = "nexusphp"
|
site.Type = "nexusphp"
|
||||||
}
|
}
|
||||||
if site.AuthType == "" {
|
if site.AuthType == "" {
|
||||||
site.AuthType = "cookie"
|
site.AuthType = "cookie"
|
||||||
}
|
}
|
||||||
if site.Timeout <= 0 {
|
|
||||||
site.Timeout = 15
|
|
||||||
}
|
|
||||||
return s.repo.DB.WithContext(ctx).Create(site).Error
|
return s.repo.DB.WithContext(ctx).Create(site).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns every site ordered by priority (lower = higher priority).
|
// List returns every site ordered by priority (lower = higher priority).
|
||||||
func (s *SiteService) List(ctx context.Context) ([]model.Site, error) {
|
func (s *SiteService) List(ctx context.Context) ([]model.Site, error) {
|
||||||
var sites []model.Site
|
var sites []model.Site
|
||||||
err := s.repo.DB.WithContext(ctx).Order("priority asc, created_at asc").Find(&sites).Error
|
err := s.repo.DB.WithContext(ctx).Order("created_at asc").Find(&sites).Error
|
||||||
return sites, err
|
return sites, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,14 +80,14 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri
|
|||||||
return false, "site not found", err
|
return false, "site not found", err
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{Timeout: time.Duration(site.Timeout) * time.Second}
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, site.BaseURL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, site.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err.Error(), nil
|
return false, err.Error(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply auth headers.
|
// Apply auth headers.
|
||||||
req.Header.Set("User-Agent", effectiveUA(site))
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
|
||||||
switch site.AuthType {
|
switch site.AuthType {
|
||||||
case "cookie":
|
case "cookie":
|
||||||
if site.Cookie != "" {
|
if site.Cookie != "" {
|
||||||
@@ -108,9 +105,9 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status := "fail"
|
now := time.Now()
|
||||||
_ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
|
_ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
|
||||||
Update("login_status", status).Error
|
Updates(map[string]any{"last_error": err.Error(), "last_check_at": &now}).Error
|
||||||
return false, err.Error(), nil
|
return false, err.Error(), nil
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -132,8 +129,9 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri
|
|||||||
if !ok {
|
if !ok {
|
||||||
loginStatus = "fail"
|
loginStatus = "fail"
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
_ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
|
_ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
|
||||||
Updates(map[string]any{"login_status": loginStatus, "last_check": time.Now()}).Error
|
Updates(map[string]any{"last_error": loginStatus, "last_check_at": &now}).Error
|
||||||
return ok, msg, nil
|
return ok, msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,18 +168,19 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul
|
|||||||
if adapter == nil {
|
if adapter == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
items, err := adapter.Search(ctx, keyword)
|
cfg := siteModelToConfig(&sites[i])
|
||||||
|
result, err := adapter.Search(ctx, cfg, keyword, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Debug("site search failed",
|
s.log.Debug("site search failed",
|
||||||
zap.String("site", sites[i].Name), zap.Error(err))
|
zap.String("site", sites[i].Name), zap.Error(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, item := range items {
|
for _, item := range result.Items {
|
||||||
results = append(results, SearchResult{
|
results = append(results, SearchResult{
|
||||||
SiteName: sites[i].Name,
|
SiteName: sites[i].Name,
|
||||||
SiteID: sites[i].ID,
|
SiteID: sites[i].ID,
|
||||||
Title: item.Title,
|
Title: item.Title,
|
||||||
TorrentURL: item.TorrentURL,
|
TorrentURL: item.DetailURL,
|
||||||
DownloadURL: item.DownloadURL,
|
DownloadURL: item.DownloadURL,
|
||||||
Size: item.Size,
|
Size: item.Size,
|
||||||
Seeders: item.Seeders,
|
Seeders: item.Seeders,
|
||||||
@@ -202,9 +201,15 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func effectiveUA(site *model.Site) string {
|
// siteModelToConfig 将 model.Site 转换为适配器使用的 SiteConfig。
|
||||||
if site.UserAgent != "" {
|
func siteModelToConfig(s *model.Site) SiteConfig {
|
||||||
return site.UserAgent
|
return SiteConfig{
|
||||||
|
Name: s.Name,
|
||||||
|
Type: s.Type,
|
||||||
|
URL: s.URL,
|
||||||
|
AuthType: s.AuthType,
|
||||||
|
Cookie: s.Cookie,
|
||||||
|
APIKey: s.APIKey,
|
||||||
|
AuthHeader: s.AuthHeader,
|
||||||
}
|
}
|
||||||
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SiteConfig 站点配置(从 model.Site 解密后的纯文本)。
|
// SiteConfig 站点配置(从 model.Site 解密后的纯文本)。
|
||||||
@@ -26,8 +28,8 @@ type SiteConfig struct {
|
|||||||
Extra map[string]string // JSON 扩展配置
|
Extra map[string]string // JSON 扩展配置
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchResult 站点搜索结果。
|
// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
|
||||||
type SearchResult struct {
|
type SiteSearchResult struct {
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
Items []TorrentItem `json:"items"`
|
Items []TorrentItem `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
@@ -78,10 +80,10 @@ type SiteAdapter interface {
|
|||||||
Authenticate(ctx context.Context, cfg SiteConfig) error
|
Authenticate(ctx context.Context, cfg SiteConfig) error
|
||||||
|
|
||||||
// Search 搜索种子。
|
// Search 搜索种子。
|
||||||
Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SearchResult, error)
|
Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error)
|
||||||
|
|
||||||
// Browse 浏览种子列表。
|
// Browse 浏览种子列表。
|
||||||
Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SearchResult, error)
|
Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error)
|
||||||
|
|
||||||
// GetDetail 获取种子详情。
|
// GetDetail 获取种子详情。
|
||||||
GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error)
|
GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error)
|
||||||
@@ -194,7 +196,7 @@ func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *NexusPHPAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SearchResult, error) {
|
func (a *NexusPHPAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("search", keyword)
|
params.Set("search", keyword)
|
||||||
params.Set("page", strconv.Itoa(page))
|
params.Set("page", strconv.Itoa(page))
|
||||||
@@ -213,7 +215,7 @@ func (a *NexusPHPAdapter) Search(ctx context.Context, cfg SiteConfig, keyword st
|
|||||||
return parseNexusPHPHTML(string(data), cfg.Name, cfg.URL)
|
return parseNexusPHPHTML(string(data), cfg.Name, cfg.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *NexusPHPAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SearchResult, error) {
|
func (a *NexusPHPAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
if category != "" {
|
if category != "" {
|
||||||
params.Set("cat", category)
|
params.Set("cat", category)
|
||||||
@@ -250,8 +252,8 @@ func (a *NexusPHPAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseNexusPHPHTML 解析 NexusPHP 种子列表 HTML。
|
// parseNexusPHPHTML 解析 NexusPHP 种子列表 HTML。
|
||||||
func parseNexusPHPHTML(html, siteName, baseURL string) (*SearchResult, error) {
|
func parseNexusPHPHTML(html, siteName, baseURL string) (*SiteSearchResult, error) {
|
||||||
result := &SearchResult{
|
result := &SiteSearchResult{
|
||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
Items: []TorrentItem{},
|
Items: []TorrentItem{},
|
||||||
Page: 1,
|
Page: 1,
|
||||||
@@ -427,7 +429,7 @@ func (a *GazelleAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *GazelleAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SearchResult, error) {
|
func (a *GazelleAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("action", "browse")
|
params.Set("action", "browse")
|
||||||
params.Set("searchstr", keyword)
|
params.Set("searchstr", keyword)
|
||||||
@@ -445,7 +447,7 @@ func (a *GazelleAdapter) Search(ctx context.Context, cfg SiteConfig, keyword str
|
|||||||
return parseGazelleJSON(data, cfg.Name, cfg.URL)
|
return parseGazelleJSON(data, cfg.Name, cfg.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *GazelleAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SearchResult, error) {
|
func (a *GazelleAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("action", "browse")
|
params.Set("action", "browse")
|
||||||
if category != "" {
|
if category != "" {
|
||||||
@@ -534,13 +536,13 @@ func (a *GazelleAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseGazelleJSON 解析 Gazelle JSON 响应。
|
// parseGazelleJSON 解析 Gazelle JSON 响应。
|
||||||
func parseGazelleJSON(data []byte, siteName, baseURL string) (*SearchResult, error) {
|
func parseGazelleJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
|
||||||
var resp map[string]interface{}
|
var resp map[string]interface{}
|
||||||
if err := json.Unmarshal(data, &resp); err != nil {
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
return nil, fmt.Errorf("parse JSON: %w", err)
|
return nil, fmt.Errorf("parse JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &SearchResult{
|
result := &SiteSearchResult{
|
||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
Items: []TorrentItem{},
|
Items: []TorrentItem{},
|
||||||
}
|
}
|
||||||
@@ -644,7 +646,7 @@ func (a *UNIT3DAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *UNIT3DAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SearchResult, error) {
|
func (a *UNIT3DAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("search", keyword)
|
params.Set("search", keyword)
|
||||||
params.Set("page", strconv.Itoa(page))
|
params.Set("page", strconv.Itoa(page))
|
||||||
@@ -661,7 +663,7 @@ func (a *UNIT3DAdapter) Search(ctx context.Context, cfg SiteConfig, keyword stri
|
|||||||
return parseUNIT3DJSON(data, cfg.Name, cfg.URL)
|
return parseUNIT3DJSON(data, cfg.Name, cfg.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *UNIT3DAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SearchResult, error) {
|
func (a *UNIT3DAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
if category != "" {
|
if category != "" {
|
||||||
params.Set("category", category)
|
params.Set("category", category)
|
||||||
@@ -734,7 +736,7 @@ func (a *UNIT3DAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseUNIT3DJSON 解析 UNIT3D JSON 响应。
|
// parseUNIT3DJSON 解析 UNIT3D JSON 响应。
|
||||||
func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SearchResult, error) {
|
func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Data []map[string]interface{} `json:"data"`
|
Data []map[string]interface{} `json:"data"`
|
||||||
Meta struct {
|
Meta struct {
|
||||||
@@ -746,7 +748,7 @@ func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SearchResult, erro
|
|||||||
return nil, fmt.Errorf("parse JSON: %w", err)
|
return nil, fmt.Errorf("parse JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &SearchResult{
|
result := &SiteSearchResult{
|
||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
Items: []TorrentItem{},
|
Items: []TorrentItem{},
|
||||||
Page: resp.Meta.CurrentPage,
|
Page: resp.Meta.CurrentPage,
|
||||||
@@ -831,7 +833,7 @@ func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SearchResult, error) {
|
func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"mode": "search",
|
"mode": "search",
|
||||||
"keyword": keyword,
|
"keyword": keyword,
|
||||||
@@ -852,7 +854,7 @@ func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword strin
|
|||||||
return parseMTeamJSON(data, cfg.Name, cfg.URL)
|
return parseMTeamJSON(data, cfg.Name, cfg.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SearchResult, error) {
|
func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"mode": "browse",
|
"mode": "browse",
|
||||||
"category": category,
|
"category": category,
|
||||||
@@ -936,7 +938,7 @@ func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseMTeamJSON 解析 MTeam JSON 响应。
|
// parseMTeamJSON 解析 MTeam JSON 响应。
|
||||||
func parseMTeamJSON(data []byte, siteName, baseURL string) (*SearchResult, error) {
|
func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
Data struct {
|
Data struct {
|
||||||
@@ -948,7 +950,7 @@ func parseMTeamJSON(data []byte, siteName, baseURL string) (*SearchResult, error
|
|||||||
return nil, fmt.Errorf("parse JSON: %w", err)
|
return nil, fmt.Errorf("parse JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &SearchResult{
|
result := &SiteSearchResult{
|
||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
Items: []TorrentItem{},
|
Items: []TorrentItem{},
|
||||||
Total: resp.Data.Total,
|
Total: resp.Data.Total,
|
||||||
@@ -1033,7 +1035,7 @@ func (a *DiscuzAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *DiscuzAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SearchResult, error) {
|
func (a *DiscuzAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("mod", "forum")
|
params.Set("mod", "forum")
|
||||||
params.Set("srchtxt", keyword)
|
params.Set("srchtxt", keyword)
|
||||||
@@ -1052,7 +1054,7 @@ func (a *DiscuzAdapter) Search(ctx context.Context, cfg SiteConfig, keyword stri
|
|||||||
return parseDiscuzHTML(string(data), cfg.Name, cfg.URL)
|
return parseDiscuzHTML(string(data), cfg.Name, cfg.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *DiscuzAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SearchResult, error) {
|
func (a *DiscuzAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
if category != "" {
|
if category != "" {
|
||||||
params.Set("fid", category)
|
params.Set("fid", category)
|
||||||
@@ -1117,8 +1119,8 @@ func (a *DiscuzAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseDiscuzHTML 解析 Discuz HTML 响应。
|
// parseDiscuzHTML 解析 Discuz HTML 响应。
|
||||||
func parseDiscuzHTML(html, siteName, baseURL string) (*SearchResult, error) {
|
func parseDiscuzHTML(html, siteName, baseURL string) (*SiteSearchResult, error) {
|
||||||
result := &SearchResult{
|
result := &SiteSearchResult{
|
||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
Items: []TorrentItem{},
|
Items: []TorrentItem{},
|
||||||
Page: 1,
|
Page: 1,
|
||||||
@@ -1183,7 +1185,7 @@ func (a *CustomRSSAdapter) Authenticate(ctx context.Context, cfg SiteConfig) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *CustomRSSAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SearchResult, error) {
|
func (a *CustomRSSAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||||||
searchURL := cfg.URL
|
searchURL := cfg.URL
|
||||||
// If extra has search URL template, use it
|
// If extra has search URL template, use it
|
||||||
if searchTpl, ok := cfg.Extra["search_url"]; ok && searchTpl != "" {
|
if searchTpl, ok := cfg.Extra["search_url"]; ok && searchTpl != "" {
|
||||||
@@ -1218,7 +1220,7 @@ func (a *CustomRSSAdapter) Search(ctx context.Context, cfg SiteConfig, keyword s
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *CustomRSSAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SearchResult, error) {
|
func (a *CustomRSSAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
|
||||||
// RSS browse is essentially the same as search with empty keyword
|
// RSS browse is essentially the same as search with empty keyword
|
||||||
return a.Search(ctx, cfg, "", page)
|
return a.Search(ctx, cfg, "", page)
|
||||||
}
|
}
|
||||||
@@ -1236,8 +1238,8 @@ func (a *CustomRSSAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, i
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseRSSXML 解析 RSS XML 内容。
|
// parseRSSXML 解析 RSS XML 内容。
|
||||||
func parseRSSXML(data []byte, siteName, keyword string) (*SearchResult, error) {
|
func parseRSSXML(data []byte, siteName, keyword string) (*SiteSearchResult, error) {
|
||||||
result := &SearchResult{
|
result := &SiteSearchResult{
|
||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
Items: []TorrentItem{},
|
Items: []TorrentItem{},
|
||||||
}
|
}
|
||||||
@@ -1385,3 +1387,8 @@ func GetAdapterForType(siteType string) SiteAdapter {
|
|||||||
return NewNexusPHPAdapter()
|
return NewNexusPHPAdapter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewSiteAdapter 根据站点模型创建对应的适配器。
|
||||||
|
func NewSiteAdapter(site *model.Site) SiteAdapter {
|
||||||
|
return GetAdapterForType(site.Type)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
@@ -15,7 +15,6 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
Home,
|
Home,
|
||||||
GalleryHorizontalEnd,
|
GalleryHorizontalEnd,
|
||||||
KeyRound,
|
|
||||||
KeySquare,
|
KeySquare,
|
||||||
Link2,
|
Link2,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
|||||||
@@ -258,8 +258,10 @@ export interface NotifyChannel {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email'
|
||||||
|
channel_type: 'telegram' | 'wechat' | 'bark' | 'webhook'
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
events: string
|
events: string
|
||||||
|
config: Record<string, string>
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user