diff --git a/.gitignore b/.gitignore index d19d024..bab536b 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,9 @@ Thumbs.db # Local configs (keep examples) config/secrets.yaml config.yaml + +# WorkBuddy workspace (local AI assistant memory) +.workbuddy/ + +# Editor backups +*~ diff --git a/.workbuddy/memory/2026-05-15.md b/.workbuddy/memory/2026-05-15.md deleted file mode 100644 index b5aaf2d..0000000 --- a/.workbuddy/memory/2026-05-15.md +++ /dev/null @@ -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 为预期行为 diff --git a/.workbuddy/memory/2026-05-16.md b/.workbuddy/memory/2026-05-16.md deleted file mode 100644 index 1761d5a..0000000 --- a/.workbuddy/memory/2026-05-16.md +++ /dev/null @@ -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) diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md deleted file mode 100644 index 7d75ee2..0000000 --- a/.workbuddy/memory/MEMORY.md +++ /dev/null @@ -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` 路由改为 `` -- **APIConfigsPanel**:紧凑表格布局(Provider | 密钥掩码 | 状态徽章 | 操作按钮),点击编辑后行内展开表单,替代原先卡片式布局 -- **文件变更**:新增 `web/src/components/APIConfigsPanel.tsx`;修改 `AdminPage.tsx`、`Layout.tsx`、`App.tsx`;保留 `APIConfigsPage.tsx` 但不再路由引用 diff --git a/README.md b/README.md index e84cc73..6e202d7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@

🎬 MediaStationGo

-

A Go rewrite of MediaStation — your private home media center.

+

+ MediaStation 的 Go 语言重写版 —— 您的私有家庭媒体中心。 +

+

+ English +

Go @@ -12,205 +17,207 @@ --- -## Why a rewrite? +## 为什么要重写? -The original MediaStation is a Python/FastAPI + Vue project. **MediaStationGo** -is a from-scratch reimplementation that adopts the lighter, single-binary -deployment model used by [`ShukeBta/MediaStation`](https://github.com/ShukeBta/MediaStation): +原版 MediaStation 是一个 Python/FastAPI + Vue 项目。**MediaStationGo** 是从零开始的全新重构,采用更轻量的单文件部署模式: -- **Backend**: Go 1.25 + Gin + GORM + SQLite (WAL). -- **Frontend**: React 18 + Vite + Tailwind + Zustand. -- **Distribution**: one ~30 MB static binary (CGO disabled), or a - multi-arch Alpine Docker image. +- **后端**:Go 1.25 + Gin + GORM + SQLite(WAL 模式) +- **前端**:React 18 + Vite + Tailwind CSS + Zustand +- **分发**:约 30 MB 纯静态二进制(CGO 禁用),或跨架构 Alpine Docker 镜像 -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. +目标是在保持用户功能和界面不变的前提下,大幅降低 NAS 设备上的部署复杂度。 --- -## 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 written for sensitive actions (login, library CRUD, downloads, …) +### 认证与用户 +- ✅ JWT 认证(admin / user 双角色) +- ✅ 首次运行自动创建管理员(`admin / admin123`,可通过 `ADMIN_INITIAL_PASSWORD` 自定义) +- ✅ 个人信息页(邮箱 / 头像 / 修改密码) +- ✅ 管理员用户表(角色提升 / 降级) +- ✅ 敏感操作审计日志(登录、媒体库操作、下载等) -### Library management -- ✅ Library CRUD + recursive filesystem scan -- ✅ ffprobe metadata extraction (duration / resolution / codecs / container) -- ✅ 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) - - tv → TheTVDB (fallback TMDb) - - anime → Bangumi (fallback TMDb) -- ✅ Image proxy with disk cache for TMDb / Bangumi / Douban / Fanart / TheTVDB -- ✅ TV / anime libraries grouped by season with episode listing -- ✅ fsnotify-based filesystem watcher with 5 s coalescing debouncer +### 媒体库管理 +- ✅ 媒体库增删改查 + 递归文件系统扫描 +- ✅ ffprobe 元数据提取(时长 / 分辨率 / 编码格式 / 容器) +- ✅ 智能文件名清洗(年份 + 季/集号识别) +- ✅ 多数据源链式刮削(按媒体库类型): + - 电影 → TMDb(可选 Fanart.tv 高清海报升级) + - 电视剧 → TheTVDB(TMDb 回退) + - 动漫 → Bangumi(TMDb 回退) +- ✅ 图片代理 + 磁盘缓存(TMDb / Bangumi / 豆瓣 / Fanart / TheTVDB) +- ✅ 电视剧 / 动漫按季分组 + 剧集列表 +- ✅ fsnotify 文件系统监听(5 秒防抖合并) -### 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) +### 播放 +- ✅ 直链播放(支持 HTTP Range) +- ✅ HLS 按需转码(每个文件独立 ffmpeg 作业) +- ✅ 外挂字幕识别(.srt / .vtt / .ass / .ssa)+ 实时 WebVTT 转换 +- ✅ 播放位置续播(每 10 秒写入)+ 首页「继续观看」 +- ✅ 收藏(切换)+ 播放列表(增删改查) -### Automation -- ✅ qBittorrent download integration (add / list / delete via Web UI API) -- ✅ RSS subscriptions with regex filters, GUID dedup and 10-minute polling +### PT 站点管理 +- ✅ 站点配置增删改查 +- ✅ 支持 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 -- ✅ Operator dashboard at `/stats` (CPU / memory / disk / library counts / Goroutines) -- ✅ 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 +### 自动化 +- ✅ qBittorrent 下载集成(添加 / 列表 / 删除) +- ✅ RSS 订阅 + 正则过滤 + GUID 去重 + 10 分钟轮询 +- ✅ 下载文件自动分类整理 -### Discovery & AI -- ✅ TMDb Discover — Trending (today) + Popular rails on `/discover` -- ✅ AI smart search (OpenAI-compatible) — natural-language queries → structured intent -- ✅ AI recommendations seeded from your watch history (`GET /api/ai/recommend`) +### 运维监控 +- ✅ 实时事件推送(扫描 / 刮削 / 转码 / 下载 / 订阅)通过 WebSocket +- ✅ 仪表盘 `/stats`(CPU / 内存 / 磁盘 / 媒体库数量 / Goroutines) +- ✅ 实时任务面板 `/tasks`(当前 ffmpeg 作业 + qBittorrent 种子) +- ✅ NFO 导出(Kodi / Jellyfin 兼容)—— 单文件或整库 +- ✅ 硬件加速编码配置:Software / NVENC / Intel QSV / VAAPI +- ✅ 单文件部署、多架构 Docker 镜像、GitHub Actions CI + GHCR -### Frontend -- ✅ React SPA with code-splitting: Login / Home / Library / Search / Favourites / - Playlists / Media detail / Player (HLS + direct + subtitles) / Profile / - Downloads / Subscriptions / Stats / Admin -- ✅ Global toast notifications driven by the WebSocket hub -- ✅ Initial bundle ~250 KB / 83 KB gzipped (hls.js loaded only on first HLS playback) +### 发现与 AI +- ✅ TMDb 发现 —— 首页热门推荐 +- ✅ AI 智能搜索(OpenAI 兼容接口)—— 自然语言 → 结构化查询 +- ✅ AI 推荐(基于观看历史)`GET /api/ai/recommend` -### 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 | ⏳ | -| Multi-bitrate ABR transcode profiles | ⏳ | +### 路线图 + +| 功能 | 状态 | +|------|------| +| Jellyfin / Emby 双向兼容层 | ⏳ | +| DLNA / Chromecast 投屏 | ⏳ | +| 在线字幕搜索 | ⏳ | +| 多码率 ABR 转码 | ⏳ | --- -## Quick start +## 快速开始 -### Docker +### 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.yml,将您的媒体目录挂载到 /media docker compose up -d ``` -Open and log in with `admin / admin123`. +打开 ,使用 `admin / admin123` 登录。 -### Bare metal +### 裸机部署 ```bash -# requirements: Go 1.25+, Node 20+, ffmpeg -make build # produces bin/mediastation-go and web/dist +# 前置要求:Go 1.25+、Node 20+、ffmpeg +make build # 生成 bin/mediastation-go 和 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 +make dev # 后端启动在 :8080,MEDIASTATION_APP_DEBUG=true +make dev-web # Vite 开发服务器启动在 :3000,代理 /api → :8080 ``` --- -## Configuration +## 配置说明 -Configuration is layered — defaults < `config.yaml` < `config/*.yaml` < -environment variables prefixed with `MEDIASTATION_`. +配置层级:默认值 < `config.yaml` < `config/*.yaml` < 环境变量(前缀 `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 | +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `MEDIASTATION_APP_PORT` | `8080` | HTTP 监听端口 | +| `MEDIASTATION_APP_DATA_DIR` | `./data` | 数据目录(数据库 / 缓存 / JWT 密钥) | +| `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | 前端 SPA 静态文件目录 | +| `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite 数据库文件路径 | +| `MEDIASTATION_SECRETS_JWT_SECRET` | *(自动生成)* | JWT 签名密钥 | +| `MEDIASTATION_SECRETS_TMDB_API_KEY` | *(空)* | 启用 TMDb 电影刮削 | +| `MEDIASTATION_SECRETS_BANGUMI_ACCESS_TOKEN` | *(空)* | 可选,提升 Bangumi 速率限制 | +| `MEDIASTATION_APP_CORS_ORIGINS` | *(空)* | 跨域白名单(JSON 数组) | +| `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.username` | qBittorrent user | -| `qbittorrent.password` | qBittorrent password | -| `qbittorrent.savepath` | Optional default save path for new torrents | +| 键 | 说明 | +|----|------| +| `qbittorrent.url` | qBittorrent Web UI 地址 | +| `qbittorrent.username` | qBittorrent 用户名 | +| `qbittorrent.password` | qBittorrent 密码 | +| `qbittorrent.savepath` | 可选,新种子默认保存路径 | -After editing, hit **下载 → 重新加载配置** (or `POST /api/downloads/reload`) so -the qBittorrent client picks up the new credentials. +编辑后点击 **下载 → 重新加载配置**(或 `POST /api/downloads/reload`)使客户端重新读取。 -See [`config.example.yaml`](config.example.yaml) for the full surface. +完整配置模板请参见 [`config.example.yaml`](config.example.yaml)。 --- -## Project layout +## 项目结构 ``` MediaStationGo/ -├── cmd/server/main.go Application entry point +├── cmd/server/main.go 应用入口 ├── 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 -│ │ ├── 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 -│ │ ├── 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 -│ ├── src/hooks/ useWebSocket, … -│ ├── src/pages/ Home / Library / Search / Player / Downloads / … -│ ├── src/stores/ Zustand (auth) -│ └── src/types/ Domain types mirrored from Go -├── Dockerfile Multi-stage, multi-arch build -├── docker-compose.yml NAS-friendly deployment +│ ├── config/ Viper 配置加载 +│ ├── database/ GORM + SQLite (WAL) 初始化 +│ ├── model/ GORM 数据模型 + AutoMigrate 注册 +│ ├── repository/ 数据访问层 +│ ├── service/ 业务逻辑 +│ │ ├── auth.go 登录 / 注册 / JWT / 管理员种子 +│ │ ├── media.go 媒体库 + 媒体 CRUD +│ │ ├── scanner.go 文件扫描 + ffprobe + 刮削触发 +│ │ ├── ffprobe.go ffprobe 封装 +│ │ ├── tmdb.go TMDb 数据源 +│ │ ├── bangumi.go Bangumi 数据源 +│ │ ├── scraper.go 刮削协调器 + 文件名清洗 +│ │ ├── site.go 站点管理(CRUD + 连接测试 + 跨站搜索) +│ │ ├── site_adapter.go 6 种 PT 站点适配器 +│ │ ├── stream.go 直链播放 + HLS 分片 +│ │ ├── transcoder.go 媒体 HLS 转码管理 +│ │ ├── subtitle.go 外挂字幕识别 + WebVTT 转换 +│ │ ├── image_proxy.go 图片代理缓存 +│ │ ├── playback.go 播放历史 / 收藏 / 播放列表 +│ │ ├── watcher.go fsnotify 文件监听 +│ │ ├── qbittorrent.go qBittorrent API 客户端 +│ │ ├── downloads.go 下载管理 + WS 轮询 +│ │ ├── subscription.go RSS 订阅轮询 +│ │ ├── stats.go 仪表盘快照 +│ │ ├── profile.go 用户信息修改 +│ │ ├── audit.go 审计日志 +│ │ ├── ws_hub.go WebSocket 发布/订阅 +│ │ ├── organizer.go 媒体文件整理 +│ │ └── walk.go / episode_parser.go 辅助工具 +│ ├── middleware/ Gin 中间件 (CORS / JWT / admin) +│ └── handler/ HTTP 路由(按功能分文件) +├── web/ React 18 + Vite 前端 +│ ├── src/api/ axios 接口封装 +│ ├── src/components/ Layout / MediaCard / GlobalEvents / RequireAuth / APIConfigsPanel +│ ├── src/hooks/ useWebSocket 等 +│ ├── src/pages/ 首页 / 媒体库 / 搜索 / 播放器 / 下载 / 管理后台 / 站点管理 +│ ├── src/stores/ Zustand 状态管理 (auth) +│ └── src/types/ 前端类型定义(与 Go 模型对齐) +├── Dockerfile 多阶段、多架构构建 +├── docker-compose.yml NAS 友好部署配置 ├── Makefile build / dev / docker / test -├── config.example.yaml Full configuration template -└── .github/workflows/ CI + GHCR publish +├── config.example.yaml 完整配置模板 +└── .github/workflows/ CI + GHCR 发布 ``` --- -## License +## 许可证 -Released under the [GNU GPL v3.0](LICENSE). +基于 [GNU GPL v3.0](LICENSE) 开源发布。 diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..d65a03a --- /dev/null +++ b/README_EN.md @@ -0,0 +1,225 @@ +

🎬 MediaStationGo

+

+ A Go rewrite of MediaStation — your private home media center. +

+

+ 中文 +

+ +

+ Go + React + TypeScript + SQLite + Docker + License +

+ +--- + +## 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 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). diff --git a/internal/handler/handler.go b/internal/handler/handler.go index e1f3377..7b82853 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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.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) ── authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(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.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. 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.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). @@ -396,38 +379,11 @@ func versionInfo(c *gin.Context) { // ─── 权限 Handler 包装 ──────────────────────────────────────────────────────── -func getUserPermissionsHandler(svc *service.Container) gin.HandlerFunc { - h := NewPermissionHandler(svc, svc.Log) - return h.GetUserPermissions -} - -func updateUserPermissionsHandler(svc *service.Container) gin.HandlerFunc { - h := NewPermissionHandler(svc, svc.Log) - return h.UpdateUserPermissions -} - -func resetUserPermissionsHandler(svc *service.Container) gin.HandlerFunc { - h := NewPermissionHandler(svc, svc.Log) - return h.ResetUserPermissions -} - func getMyPermissionsHandler(svc *service.Container) gin.HandlerFunc { h := NewPermissionHandler(svc, svc.Log) return h.GetMyPermissions } -// ─── 刷新 Handler 包装 ──────────────────────────────────────────────────────── - -func refreshHandler(svc *service.Container) gin.HandlerFunc { - h := NewRefreshHandler(svc, svc.Log) - return h.RefreshToken -} - -func logoutHandler(svc *service.Container) gin.HandlerFunc { - h := NewRefreshHandler(svc, svc.Log) - return h.Logout -} - // ─── API Config Handler 包装 ─────────────────────────────────────────────────── func listApiConfigsHandler(svc *service.Container) gin.HandlerFunc { @@ -467,73 +423,23 @@ func testApiConfigHandler(svc *service.Container) gin.HandlerFunc { // ─── Download Client Handler 包装 ───────────────────────────────────────────── -func listDownloadClientsHandler(svc *service.Container) gin.HandlerFunc { - h := NewDownloadClientHandler(svc, svc.Log) - return h.List -} - -func createDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - h := NewDownloadClientHandler(svc, svc.Log) - return h.Create -} - func getDownloadClientHandler(svc *service.Container) gin.HandlerFunc { h := NewDownloadClientHandler(svc, svc.Log) return h.Get } -func updateDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - h := NewDownloadClientHandler(svc, svc.Log) - return h.Update -} - -func deleteDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - h := NewDownloadClientHandler(svc, svc.Log) - return h.Delete -} - -func testDownloadClientHandler(svc *service.Container) gin.HandlerFunc { - h := NewDownloadClientHandler(svc, svc.Log) - return h.Test -} - // ─── Notify Channel Handler 包装 ────────────────────────────────────────────── -func listNotifyChannelsHandler(svc *service.Container) gin.HandlerFunc { - h := NewNotifyHandler(svc, svc.Log) - return h.List -} - func getNotifyChannelTypesHandler(svc *service.Container) gin.HandlerFunc { h := NewNotifyHandler(svc, svc.Log) return h.GetTypes } -func createNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - h := NewNotifyHandler(svc, svc.Log) - return h.Create -} - func getNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { h := NewNotifyHandler(svc, svc.Log) return h.Get } -func updateNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - h := NewNotifyHandler(svc, svc.Log) - return h.Update -} - -func deleteNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - h := NewNotifyHandler(svc, svc.Log) - return h.Delete -} - -func testNotifyChannelHandler(svc *service.Container) gin.HandlerFunc { - h := NewNotifyHandler(svc, svc.Log) - return h.Test -} - // ─── Scheduler Handler 包装 ────────────────────────────────────────────────── func schedulerListTasksHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/license.go b/internal/handler/license.go deleted file mode 100644 index 6ba15b0..0000000 --- a/internal/handler/license.go +++ /dev/null @@ -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) - } -} diff --git a/internal/handler/permission_handler.go b/internal/handler/permission_handler.go index 59fec67..7cd60e7 100644 --- a/internal/handler/permission_handler.go +++ b/internal/handler/permission_handler.go @@ -8,6 +8,7 @@ import ( "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/middleware" + "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/service" ) @@ -38,7 +39,7 @@ func (h *PermissionHandler) GetUserPermissions(c *gin.Context) { return } - perms, err := h.svc.Permission.GetByUserID(c.Request.Context(), userID) + perms, err := h.svc.Permissions.Effective(c.Request.Context(), userID) if err != nil { h.log.Error("get user permissions failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil}) @@ -64,15 +65,13 @@ func (h *PermissionHandler) UpdateUserPermissions(c *gin.Context) { return } - var req struct { - Permissions map[string]bool `json:"permissions"` - } + var req model.UserPermission if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "invalid request", "data": nil}) return } - if err := h.svc.Permission.Update(c.Request.Context(), userID, req.Permissions); err != nil { + 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)) c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil}) return @@ -97,7 +96,7 @@ func (h *PermissionHandler) ResetUserPermissions(c *gin.Context) { 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)) c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil}) return @@ -115,7 +114,7 @@ func (h *PermissionHandler) GetMyPermissions(c *gin.Context) { return } - perms, err := h.svc.Permission.GetPermissionMap(c.Request.Context(), currentUserID) + perms, err := h.svc.Permissions.Effective(c.Request.Context(), currentUserID) if err != nil { h.log.Error("get my permissions failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil}) @@ -127,7 +126,7 @@ func (h *PermissionHandler) GetMyPermissions(c *gin.Context) { tier := middleware.GetUserTier(c) c.JSON(http.StatusOK, gin.H{ - "code": 0, + "code": 0, "message": "ok", "data": gin.H{ "permissions": perms, diff --git a/internal/handler/site_handler.go b/internal/handler/site_handler.go index 1d24701..4b4324e 100644 --- a/internal/handler/site_handler.go +++ b/internal/handler/site_handler.go @@ -33,17 +33,17 @@ func (h *SiteHandler) ListSites(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": sites}) } -// GetSite 获取单个站点详情(解密敏感字段)。 +// GetSite 获取单个站点详情。 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 == service.ErrSiteNotFound { - c.JSON(http.StatusNotFound, gin.H{"code": 1, "message": "site not found"}) - return - } c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()}) return } + 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}) } @@ -54,41 +54,30 @@ func (h *SiteHandler) CreateSite(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) return } - created, err := h.svc.Site.Create(c.Request.Context(), &site) - if err != nil { + if err := h.svc.Site.Create(c.Request.Context(), &site); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) return } - c.JSON(http.StatusCreated, gin.H{"code": 0, "message": "ok", "data": created}) + c.JSON(http.StatusCreated, gin.H{"code": 0, "message": "ok", "data": site}) } // UpdateSite 更新站点。 func (h *SiteHandler) UpdateSite(c *gin.Context) { - var site model.Site - if err := c.ShouldBindJSON(&site); err != nil { + patch := make(map[string]any) + if err := c.ShouldBindJSON(&patch); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) return } - site.ID = c.Param("id") - updated, err := h.svc.Site.Update(c.Request.Context(), &site) - if err != nil { - if err == service.ErrSiteNotFound { - c.JSON(http.StatusNotFound, gin.H{"code": 1, "message": "site not found"}) - return - } + if err := h.svc.Site.Update(c.Request.Context(), c.Param("id"), patch); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": updated}) + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok"}) } // DeleteSite 删除站点。 func (h *SiteHandler) DeleteSite(c *gin.Context) { if err := h.svc.Site.Delete(c.Request.Context(), c.Param("id")); err != nil { - if err == service.ErrSiteNotFound { - c.JSON(http.StatusNotFound, gin.H{"code": 1, "message": "site not found"}) - return - } c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()}) return } @@ -97,11 +86,16 @@ func (h *SiteHandler) DeleteSite(c *gin.Context) { // TestSite 测试站点连通性。 func (h *SiteHandler) TestSite(c *gin.Context) { - if err := h.svc.Site.Authenticate(c.Request.Context(), c.Param("id")); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 1, "message": err.Error()}) + ok, msg, err := h.svc.Site.TestConnection(c.Request.Context(), c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "message": err.Error()}) 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 返回支持的站点类型列表。 diff --git a/internal/handler/sites.go b/internal/handler/sites.go index 274c648..1aad28e 100644 --- a/internal/handler/sites.go +++ b/internal/handler/sites.go @@ -2,6 +2,7 @@ package handler import ( + "encoding/json" "net/http" "github.com/gin-gonic/gin" @@ -66,21 +67,36 @@ func createSiteHandler(svc *service.Container) gin.HandlerFunc { if req.Enabled != nil { 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{ Name: req.Name, - BaseURL: req.BaseURL, - SiteType: req.SiteType, + URL: req.BaseURL, + Type: req.SiteType, AuthType: req.AuthType, Cookie: req.Cookie, APIKey: req.APIKey, AuthHeader: req.AuthHeader, - UserAgent: req.UserAgent, - RSSURL: req.RSSURL, - Timeout: req.Timeout, - Priority: req.Priority, - UseProxy: req.UseProxy, + Extra: string(extraJSON), Enabled: enabled, - Downloader: req.Downloader, } if err := svc.Site.Create(c.Request.Context(), site); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/internal/handler/sites_extra.go b/internal/handler/sites_extra.go index e897a2a..9f41a60 100644 --- a/internal/handler/sites_extra.go +++ b/internal/handler/sites_extra.go @@ -50,11 +50,17 @@ func siteUserdataHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "site not found"}) return } + loginStatus := "unknown" + if s.LastError == "ok" { + loginStatus = "ok" + } else if s.LastError != "" { + loginStatus = "fail" + } c.JSON(http.StatusOK, gin.H{ "site_id": s.ID, "name": s.Name, "cookie_set": len(s.Cookie) > 0, - "login_status": s.LoginStatus, + "login_status": loginStatus, "note": "userdata parsing not implemented; stub", }) } diff --git a/internal/model/model.go b/internal/model/model.go index 91d32d9..69b4c36 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -204,51 +204,6 @@ type AccessLog struct { 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 // different content-rating limits, library access, and player defaults. // 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"` } -// 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 // backend (Alist / S3 / WebDAV). Type column makes the row poly-typed // — 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"` } -// 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. type AssistantSession struct { Base @@ -397,8 +289,6 @@ func AllModels() []interface{} { &STRMRecord{}, &PlayProfile{}, &StorageConfig{}, - &LicenseKey{}, - &LicenseActivation{}, &AssistantSession{}, &AssistantMessage{}, } diff --git a/internal/repository/assistant_repo.go b/internal/repository/assistant_repo.go new file mode 100644 index 0000000..b2bcd6c --- /dev/null +++ b/internal/repository/assistant_repo.go @@ -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 +} diff --git a/internal/repository/play_profile_repo.go b/internal/repository/play_profile_repo.go new file mode 100644 index 0000000..6baca63 --- /dev/null +++ b/internal/repository/play_profile_repo.go @@ -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 +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 303f427..8cc1880 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -38,6 +38,9 @@ type Container struct { NotifyChannel *NotifyChannelRepository Site *SiteRepository STRM *STRMRepository + PlayProfile *PlayProfileRepository + StorageConfig *StorageConfigRepository + Assistant *AssistantRepository } // New 将每个 repository 连接到单个 *gorm.DB。 @@ -62,6 +65,9 @@ func New(db *gorm.DB) *Container { NotifyChannel: &NotifyChannelRepository{db: db}, Site: &SiteRepository{db: db}, STRM: &STRMRepository{db: db}, + PlayProfile: &PlayProfileRepository{db: db}, + StorageConfig: &StorageConfigRepository{db: db}, + Assistant: &AssistantRepository{db: db}, } } diff --git a/internal/repository/storage_config_repo.go b/internal/repository/storage_config_repo.go new file mode 100644 index 0000000..0630911 --- /dev/null +++ b/internal/repository/storage_config_repo.go @@ -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 +} diff --git a/internal/service/download_clients.go b/internal/service/download_clients.go index 27c296a..21a7571 100644 --- a/internal/service/download_clients.go +++ b/internal/service/download_clients.go @@ -39,10 +39,9 @@ func NewDownloadClientService(log *zap.Logger, repo *repository.Container) *Down type DownloadClientInput struct { Name string `json:"name" 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"` Password string `json:"password,omitempty"` - SavePath string `json:"save_path,omitempty"` IsDefault bool `json:"is_default"` Enabled bool `json:"enabled"` } @@ -60,10 +59,9 @@ func (s *DownloadClientService) Create(ctx context.Context, in DownloadClientInp c := &model.DownloadClient{ Name: strings.TrimSpace(in.Name), Type: in.Type, - URL: strings.TrimSpace(in.URL), + Host: strings.TrimSpace(in.Host), Username: in.Username, Password: in.Password, - SavePath: in.SavePath, IsDefault: in.IsDefault, Enabled: in.Enabled, } @@ -81,9 +79,8 @@ func (s *DownloadClientService) Update(ctx context.Context, id string, in Downlo patch := map[string]any{ "name": strings.TrimSpace(in.Name), "type": in.Type, - "url": strings.TrimSpace(in.URL), + "host": strings.TrimSpace(in.Host), "username": in.Username, - "save_path": in.SavePath, "is_default": in.IsDefault, "enabled": in.Enabled, } @@ -91,7 +88,24 @@ func (s *DownloadClientService) Update(ctx context.Context, id string, in Downlo if 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 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) req, _ := http.NewRequestWithContext( ctx, http.MethodPost, - strings.TrimRight(c.URL, "/")+"/api/v2/auth/login", + strings.TrimRight(c.Host, "/")+"/api/v2/auth/login", strings.NewReader(body.Encode()), ) 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 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) if err != nil { 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"]}`, c.Password, ) - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.Host, strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp, err := s.client.Do(req) @@ -183,8 +197,8 @@ func validateClient(in DownloadClientInput) error { if strings.TrimSpace(in.Name) == "" { return errors.New("name required") } - if strings.TrimSpace(in.URL) == "" { - return errors.New("url required") + if strings.TrimSpace(in.Host) == "" { + return errors.New("host required") } switch in.Type { case "qbittorrent", "aria2", "transmission": diff --git a/internal/service/license.go b/internal/service/license.go deleted file mode 100644 index 3e7275d..0000000 --- a/internal/service/license.go +++ /dev/null @@ -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() -} diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go index 31dd924..55cc9d5 100644 --- a/internal/service/notify_channels.go +++ b/internal/service/notify_channels.go @@ -41,11 +41,11 @@ func NewNotifyChannelService(log *zap.Logger, repo *repository.Container) *Notif // ChannelInput is the shape accepted by Create / Update. Config is a // generic map; it gets serialised to JSON before being persisted. type ChannelInput struct { - Name string `json:"name" binding:"required"` - ChannelType string `json:"channel_type" binding:"required"` - Config map[string]any `json:"config"` - Events []string `json:"events"` - Enabled *bool `json:"enabled,omitempty"` + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required"` + Config map[string]any `json:"config"` + Events []string `json:"events"` + Enabled *bool `json:"enabled,omitempty"` } // 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) n := &model.NotifyChannel{ Name: strings.TrimSpace(in.Name), - ChannelType: in.ChannelType, + Type: in.Type, Config: string(cfgBlob), Events: string(evBlob), Enabled: true, @@ -119,15 +119,30 @@ func (s *NotifyChannelService) Update(ctx context.Context, id string, in Channel cfgBlob, _ := json.Marshal(in.Config) evBlob, _ := json.Marshal(in.Events) patch := map[string]any{ - "name": strings.TrimSpace(in.Name), - "channel_type": in.ChannelType, - "config": string(cfgBlob), - "events": string(evBlob), + "name": strings.TrimSpace(in.Name), + "type": in.Type, + "config": string(cfgBlob), + "events": string(evBlob), } if in.Enabled != nil { 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 } 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{} _ = json.Unmarshal([]byte(n.Config), &cfg) - switch n.ChannelType { + switch n.Type { case "telegram": token := str(cfg["bot_token"]) chat := str(cfg["chat_id"]) @@ -279,7 +294,7 @@ func (s *NotifyChannelService) dispatchOne(ctx context.Context, n model.NotifyCh } 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 { @@ -300,10 +315,10 @@ func validateChannel(in ChannelInput) error { if strings.TrimSpace(in.Name) == "" { return errors.New("name required") } - switch in.ChannelType { + switch in.Type { case "telegram", "wechat", "bark", "webhook": default: - return fmt.Errorf("unsupported channel type %q", in.ChannelType) + return fmt.Errorf("unsupported channel type %q", in.Type) } return nil } diff --git a/internal/service/permission.go b/internal/service/permission.go index c93d69e..6b83306 100644 --- a/internal/service/permission.go +++ b/internal/service/permission.go @@ -40,7 +40,7 @@ func DefaultPermissions(userID string) *model.UserPermission { CanManageSubscriptions: false, CanManageSites: false, CanManageFiles: false, - CanManageSTRM: false, + CanManageStrm: false, CanUseAIAssistant: false, CanAccessSettings: false, } @@ -59,7 +59,7 @@ func adminGrant(userID string) *model.UserPermission { CanManageSubscriptions: true, CanManageSites: true, CanManageFiles: true, - CanManageSTRM: true, + CanManageStrm: true, CanCast: true, CanUseAIAssistant: true, CanAccessSettings: true, @@ -79,7 +79,7 @@ func (s *PermissionService) Effective(ctx context.Context, userID string) (*mode if u.Role == "admin" { return adminGrant(userID), nil } - row, err := s.repo.Permission.Get(ctx, userID) + row, err := s.repo.Permission.FindByUserID(ctx, userID) if err != nil { 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 // patch. 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 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). func (s *PermissionService) Save(ctx context.Context, userID string, in *model.UserPermission) error { 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. func (s *PermissionService) Reset(ctx context.Context, userID string) (*model.UserPermission, error) { 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 def, nil diff --git a/internal/service/permission_svc.go b/internal/service/permission_svc.go deleted file mode 100644 index 1d1edab..0000000 --- a/internal/service/permission_svc.go +++ /dev/null @@ -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" -} diff --git a/internal/service/service.go b/internal/service/service.go index 42c731c..34027a3 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -57,7 +57,6 @@ type Container struct { PlayProfiles *PlayProfileService Permissions *PermissionService StorageCfg *StorageConfigService - License *LicenseService DownloadClients *DownloadClientService Assistant *AssistantService Organizer *OrganizerService @@ -108,21 +107,18 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont playProfiles := NewPlayProfileService(log, repos) permissions := NewPermissionService(log, repos) storageCfg := NewStorageConfigService(log, repos, crypto) - licenseSvc := NewLicenseService(log, repos) downloadClients := NewDownloadClientService(log, repos) assistant := NewAssistantService(log, repos, ai) organizer := NewOrganizerService(cfg, log, repos) douban := NewDoubanProvider(cfg, log) - siteService := NewSiteService(log, repos) scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir) // 初始化认证相关服务 tokenSvc := NewTokenService(cfg, log, repos) - permissionSvc := NewPermissionService(cfg, log, repos) apiConfigSvc := NewApiConfigService(cfg, log, repos, crypto) downloadMgr := NewDownloadManager(log, repos, crypto) notifySvc := NewNotifyService(log, repos, crypto) - siteSvc := NewSiteService(log, repos, crypto) + siteSvc := NewSiteService(log, repos) ctx, cancel := context.WithCancel(context.Background()) @@ -132,7 +128,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont Repo: repos, WSHub: hub, SSEHub: sseHub, - Auth: NewAuthService(cfg, log, repos, tokenSvc, permissionSvc), + Auth: NewAuthService(cfg, log, repos, tokenSvc, permissions), Media: NewMediaService(cfg, log, repos), Scan: scanner, Stream: NewStreamService(cfg, log, repos, transcoder), @@ -169,7 +165,6 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont PlayProfiles: playProfiles, Permissions: permissions, StorageCfg: storageCfg, - License: licenseSvc, DownloadClients: downloadClients, Assistant: assistant, Organizer: organizer, diff --git a/internal/service/site.go b/internal/service/site.go index b4694d3..406c912 100644 --- a/internal/service/site.go +++ b/internal/service/site.go @@ -32,26 +32,23 @@ func NewSiteService(log *zap.Logger, repo *repository.Container) *SiteService { // Create persists a new site. func (s *SiteService) Create(ctx context.Context, site *model.Site) error { - if strings.TrimSpace(site.Name) == "" || strings.TrimSpace(site.BaseURL) == "" { - return errors.New("name and base_url required") + if strings.TrimSpace(site.Name) == "" || strings.TrimSpace(site.URL) == "" { + return errors.New("name and url required") } - site.BaseURL = strings.TrimRight(site.BaseURL, "/") - if site.SiteType == "" { - site.SiteType = "nexusphp" + site.URL = strings.TrimRight(site.URL, "/") + if site.Type == "" { + site.Type = "nexusphp" } if site.AuthType == "" { site.AuthType = "cookie" } - if site.Timeout <= 0 { - site.Timeout = 15 - } return s.repo.DB.WithContext(ctx).Create(site).Error } // List returns every site ordered by priority (lower = higher priority). func (s *SiteService) List(ctx context.Context) ([]model.Site, error) { 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 } @@ -83,14 +80,14 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri return false, "site not found", err } - client := &http.Client{Timeout: time.Duration(site.Timeout) * time.Second} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, site.BaseURL, nil) + client := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, site.URL, nil) if err != nil { return false, err.Error(), nil } // 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 { case "cookie": if site.Cookie != "" { @@ -108,9 +105,9 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri resp, err := client.Do(req) if err != nil { - status := "fail" + now := time.Now() _ = 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 } defer resp.Body.Close() @@ -132,8 +129,9 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri if !ok { loginStatus = "fail" } + now := time.Now() _ = 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 } @@ -170,18 +168,19 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul if adapter == nil { continue } - items, err := adapter.Search(ctx, keyword) + cfg := siteModelToConfig(&sites[i]) + result, err := adapter.Search(ctx, cfg, keyword, 1) if err != nil { s.log.Debug("site search failed", zap.String("site", sites[i].Name), zap.Error(err)) continue } - for _, item := range items { + for _, item := range result.Items { results = append(results, SearchResult{ SiteName: sites[i].Name, SiteID: sites[i].ID, Title: item.Title, - TorrentURL: item.TorrentURL, + TorrentURL: item.DetailURL, DownloadURL: item.DownloadURL, Size: item.Size, Seeders: item.Seeders, @@ -202,9 +201,15 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul return results, nil } -func effectiveUA(site *model.Site) string { - if site.UserAgent != "" { - return site.UserAgent +// siteModelToConfig 将 model.Site 转换为适配器使用的 SiteConfig。 +func siteModelToConfig(s *model.Site) SiteConfig { + 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" } diff --git a/internal/service/site_adapter.go b/internal/service/site_adapter.go index cce5548..5477a1d 100644 --- a/internal/service/site_adapter.go +++ b/internal/service/site_adapter.go @@ -12,6 +12,8 @@ import ( "strconv" "strings" "time" + + "github.com/ShukeBta/MediaStationGo/internal/model" ) // SiteConfig 站点配置(从 model.Site 解密后的纯文本)。 @@ -26,8 +28,8 @@ type SiteConfig struct { Extra map[string]string // JSON 扩展配置 } -// SearchResult 站点搜索结果。 -type SearchResult struct { +// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。 +type SiteSearchResult struct { SiteName string `json:"site_name"` Items []TorrentItem `json:"items"` Total int `json:"total"` @@ -78,10 +80,10 @@ type SiteAdapter interface { Authenticate(ctx context.Context, cfg SiteConfig) error // 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(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(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 } -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.Set("search", keyword) 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) } -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{} if category != "" { params.Set("cat", category) @@ -250,8 +252,8 @@ func (a *NexusPHPAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id } // parseNexusPHPHTML 解析 NexusPHP 种子列表 HTML。 -func parseNexusPHPHTML(html, siteName, baseURL string) (*SearchResult, error) { - result := &SearchResult{ +func parseNexusPHPHTML(html, siteName, baseURL string) (*SiteSearchResult, error) { + result := &SiteSearchResult{ SiteName: siteName, Items: []TorrentItem{}, Page: 1, @@ -427,7 +429,7 @@ func (a *GazelleAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error 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.Set("action", "browse") 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) } -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.Set("action", "browse") if category != "" { @@ -534,13 +536,13 @@ func (a *GazelleAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id } // 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{} if err := json.Unmarshal(data, &resp); err != nil { return nil, fmt.Errorf("parse JSON: %w", err) } - result := &SearchResult{ + result := &SiteSearchResult{ SiteName: siteName, Items: []TorrentItem{}, } @@ -644,7 +646,7 @@ func (a *UNIT3DAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error 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.Set("search", keyword) 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) } -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{} if category != "" { params.Set("category", category) @@ -734,7 +736,7 @@ func (a *UNIT3DAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id s } // parseUNIT3DJSON 解析 UNIT3D JSON 响应。 -func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SearchResult, error) { +func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) { var resp struct { Data []map[string]interface{} `json:"data"` Meta struct { @@ -746,7 +748,7 @@ func parseUNIT3DJSON(data []byte, siteName, baseURL string) (*SearchResult, erro return nil, fmt.Errorf("parse JSON: %w", err) } - result := &SearchResult{ + result := &SiteSearchResult{ SiteName: siteName, Items: []TorrentItem{}, Page: resp.Meta.CurrentPage, @@ -831,7 +833,7 @@ func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error { 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{}{ "mode": "search", "keyword": keyword, @@ -852,7 +854,7 @@ func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword strin 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{}{ "mode": "browse", "category": category, @@ -936,7 +938,7 @@ func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id st } // parseMTeamJSON 解析 MTeam JSON 响应。 -func parseMTeamJSON(data []byte, siteName, baseURL string) (*SearchResult, error) { +func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) { var resp struct { Code int `json:"code"` Data struct { @@ -948,7 +950,7 @@ func parseMTeamJSON(data []byte, siteName, baseURL string) (*SearchResult, error return nil, fmt.Errorf("parse JSON: %w", err) } - result := &SearchResult{ + result := &SiteSearchResult{ SiteName: siteName, Items: []TorrentItem{}, Total: resp.Data.Total, @@ -1033,7 +1035,7 @@ func (a *DiscuzAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error 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.Set("mod", "forum") 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) } -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{} if category != "" { params.Set("fid", category) @@ -1117,8 +1119,8 @@ func (a *DiscuzAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id s } // parseDiscuzHTML 解析 Discuz HTML 响应。 -func parseDiscuzHTML(html, siteName, baseURL string) (*SearchResult, error) { - result := &SearchResult{ +func parseDiscuzHTML(html, siteName, baseURL string) (*SiteSearchResult, error) { + result := &SiteSearchResult{ SiteName: siteName, Items: []TorrentItem{}, Page: 1, @@ -1183,7 +1185,7 @@ func (a *CustomRSSAdapter) Authenticate(ctx context.Context, cfg SiteConfig) err 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 // If extra has search URL template, use it 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 } -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 return a.Search(ctx, cfg, "", page) } @@ -1236,8 +1238,8 @@ func (a *CustomRSSAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, i } // parseRSSXML 解析 RSS XML 内容。 -func parseRSSXML(data []byte, siteName, keyword string) (*SearchResult, error) { - result := &SearchResult{ +func parseRSSXML(data []byte, siteName, keyword string) (*SiteSearchResult, error) { + result := &SiteSearchResult{ SiteName: siteName, Items: []TorrentItem{}, } @@ -1385,3 +1387,8 @@ func GetAdapterForType(siteType string) SiteAdapter { return NewNexusPHPAdapter() } } + +// NewSiteAdapter 根据站点模型创建对应的适配器。 +func NewSiteAdapter(site *model.Site) SiteAdapter { + return GetAdapterForType(site.Type) +} diff --git a/internal/service/site_search_svc.go b/internal/service/site_search_svc.go deleted file mode 100644 index 2651c26..0000000 --- a/internal/service/site_search_svc.go +++ /dev/null @@ -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) -} diff --git a/internal/service/site_svc.go b/internal/service/site_svc.go deleted file mode 100644 index d674832..0000000 --- a/internal/service/site_svc.go +++ /dev/null @@ -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 -} diff --git a/server.exe~ b/server.exe~ deleted file mode 100644 index 81d7b82..0000000 Binary files a/server.exe~ and /dev/null differ diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index d986849..4a1d2cd 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -15,7 +15,6 @@ import { Heart, Home, GalleryHorizontalEnd, - KeyRound, KeySquare, Link2, ListChecks, diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 4b6a623..7939cc6 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -258,8 +258,10 @@ export interface NotifyChannel { id: string name: string type: 'telegram' | 'wechat' | 'bark' | 'webhook' | 'email' + channel_type: 'telegram' | 'wechat' | 'bark' | 'webhook' enabled: boolean events: string + config: Record created_at: string updated_at: string }