diff --git a/AGENTS.md b/AGENTS.md index 6a387ff..766f59c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,33 +1,34 @@ # AGENTS.md -> 本项目(阿里云 VOD 媒体库管理器)的代理开发指南。 +> 本项目(火炬 VOD 管理器)的代理开发指南。 ## 项目背景 -一个轻量、开源、可自托管的阿里云视频点播(VOD)媒体资源管理后台。核心目标是解决阿里云官方 VOD 控制台在查看、批量下载、批量删除大量视频时操作繁琐的问题。 +一个轻量、开源的阿里云视频点播(VOD)媒体资源管理工具,核心目标是解决阿里云官方 VOD 控制台在查看、批量下载、批量删除大量视频时操作繁琐的问题。 +- 名称:火炬 VOD 管理器 - 许可证:MIT - 用户群体:开发者、内容运营人员 -- 部署方式:Docker / 源码运行 +- 部署方式:macOS 原生应用(目标上架 Mac App Store) ## 技术栈 -- **前端**:React 18 + TypeScript + Vite + Ant Design 5 + Zustand -- **后端**:Node.js 20 + Express + TypeScript + SQLite -- **阿里云 SDK**:`@alicloud/pop-core` -- **容器化**:Docker + Docker Compose +- **客户端**:Swift 6 + SwiftUI + SwiftData +- **阿里云 API**:自实现 POP 签名,直接调用 VOD OpenAPI +- **网络**:URLSession +- **持久化**:SwiftData(SQLite) + +> 历史版本(已废弃):React 18 + Express + SQLite 的 Web 版保留在 `apps/web` 和 `apps/server`,不再维护。 ## 目录结构 ``` . ├── apps/ -│ ├── web/ # React 前端 -│ └── server/ # Express 后端 -├── docker/ -│ ├── Dockerfile -│ ├── docker-compose.yml -│ └── .env.example +│ ├── macos/ # macOS 原生 SwiftUI 应用 +│ │ └── VODManager/ +│ ├── web/ # React 前端(已废弃,历史参考) +│ └── server/ # Express 后端(已废弃,历史参考) ├── docs/ # 部署与使用文档 ├── PRD.md # 产品需求文档 ├── README.md @@ -38,74 +39,48 @@ ### 代码风格 -- 使用 TypeScript,严格模式开启。 -- 后端统一使用 `asyncHandler` 包装异步路由处理器,避免未捕获异常导致进程崩溃。 -- 后端数据库查询结果使用 camelCase 别名与 TypeScript 接口保持一致。 -- 前端使用函数组件 + Hooks,状态管理使用 Zustand。 -- API 响应统一格式:`{ code: number, data: T, message?: string }`。 +- 使用 Swift 6,严格并发检查。 +- SwiftData 模型类使用 `@Model`,避免在并发任务中直接传递 `@Model` 实例。 +- UI 使用 SwiftUI,状态管理使用 `@StateObject` / `@ObservedObject` / `@Query`。 +- 阿里云 API 调用统一封装在 `VODClient` actor 中。 +- 下载任务使用 `URLSessionDownloadTask`,文件名自动清理非法字符。 ### 安全要求 -- AccessKey Secret 必须加密存储(`APP_ENCRYPTION_KEY`),生产环境必须设置。 -- JWT 密钥生产环境必须修改,默认密钥不允许用于生产。 -- 阿里云 API 调用仅在后端进行,禁止前端直接持有密钥。 +- AccessKey Secret 存储在 SwiftData 中,建议后续接入 Keychain 加密存储。 +- 阿里云 API 调用直接从客户端发起,不经过第三方后端。 - 建议用户创建最小权限 RAM 子用户。 -### 环境变量 - -后端关键配置见 `apps/server/.env.example`: - -- `PORT`:服务端端口 -- `JWT_SECRET`:JWT 签名密钥 -- `APP_ENCRYPTION_KEY`:AccessKey Secret 加密密钥 -- `DEFAULT_ADMIN_USERNAME/PASSWORD`:默认管理员账号 -- `DB_PATH`:SQLite 数据库路径 - -## 常用命令 +### 常用命令 ```bash -# 安装依赖 -npm install +# 构建 macOS 应用 +cd apps/macos/VODManager +swift build -# 开发启动(同时启动前后端) -npm run dev +# 运行 +cd apps/macos/VODManager +swift run -# 单独启动后端 -npm run dev -w apps/server - -# 单独启动前端 -npm run dev -w apps/web - -# 代码检查 -npm run lint - -# 测试 -npm run test - -# 生产构建 -npm run build - -# 生产运行 -npm run start -w apps/server +# 在 Xcode 中打开 +open apps/macos/VODManager/Package.swift ``` -## 后端路由约定 +## App Store 注意事项 -- `/api/auth`:登录与当前用户 -- `/api/accounts`:阿里云账号配置(需管理员权限修改) -- `/api/videos`:视频列表、详情、批量下载/删除/更新 -- `/api/categories`:VOD 分类树 -- `/api/logs`:操作日志 +- 必须开启 App Sandbox。 +- 使用 `com.apple.security.network.client` 访问阿里云 API。 +- 使用 `com.apple.security.files.user-selected.read-write` 让用户选择下载目录。 +- 默认下载到应用沙盒内部,提供「导出」功能让用户移动到外部目录。 -## 数据库表 +## 数据库模型 -- `users`:用户表(id, username, password_hash, role, created_at) -- `accounts`:阿里云账号表(access_key_id, access_key_secret, region, endpoint, is_active, ...) -- `operation_logs`:操作日志表(action, target_type, target_ids, details, ...) +- `Account`:阿里云账号配置 +- `OperationLog`:操作日志 +- `DownloadTask`:下载任务历史 ## 贡献注意事项 -1. 修改前后端代码后,必须跑通 `npm run lint` 和 `npm run test`。 -2. 后端新增接口建议同步写入 `docs/api.md`(如存在)。 -3. 涉及数据库 schema 变更时,需考虑现有部署的兼容性。 -4. 提交信息使用中文,描述清晰。 +1. macOS 修改后必须能通过 `swift build`。 +2. 提交信息使用中文,描述清晰。 +3. 涉及 SwiftData schema 变更时,需考虑现有用户数据迁移。 diff --git a/MEMORY.md b/MEMORY.md index 44f5ea4..8f9fbd2 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -4,68 +4,75 @@ ## 当前状态 -- **版本**:v0.1.0(MVP) -- **阶段**:基础功能已完成,前后端均可构建并运行 -- **最后更新**:2026-06-29 +- **版本**:v0.2.0(macOS 原生版 MVP) +- **阶段**:已废弃 Web 版,macOS SwiftUI 原生版可构建运行 +- **最后更新**:2026-06-30 ## 已完成的里程碑 -### MVP(v0.1.0) +### v0.1.0(Web 版,已废弃) -- [x] 前后端项目初始化 -- [x] SQLite 数据库与默认管理员账号 -- [x] JWT 登录认证与管理员/只读角色 -- [x] 阿里云 VOD 账号配置(多账号、切换、Secret 加密) -- [x] 视频列表、搜索、筛选 -- [x] 视频详情查看 -- [x] 批量获取下载地址(支持指定清晰度) -- [x] 批量删除视频 -- [x] 批量更新视频元信息 -- [x] VOD 分类树读取 -- [x] 操作日志记录 -- [x] Docker + Docker Compose 部署配置 -- [x] README 与部署文档 -- [x] GitHub Actions CI 配置 +- [x] React + Express 前后端项目 +- [x] JWT 登录、SQLite 持久化 +- [x] VOD 账号配置与 API 代理 +- [x] 视频列表、批量下载/删除 + +### v0.2.0(macOS 原生版) + +- [x] SwiftUI macOS 项目结构 +- [x] 自实现阿里云 POP 签名(CryptoKit HMAC-SHA1) +- [x] 直接调用 VOD OpenAPI,无需 Node 后端 +- [x] SwiftData 持久化(Account / OperationLog / DownloadTask) +- [x] 视频列表、搜索 +- [x] 批量下载到用户选择目录 +- [x] 下载文件自动重命名(清理非法字符) +- [x] 浏览器下载与下载工具(aria2 / wget)两种方式 +- [x] 下载任务历史 +- [x] 操作日志 +- [x] App Sandbox entitlements 配置 ## 架构决策 -1. **前后端分离**:React 前端 + Express 后端,生产环境由后端 serve 前端静态资源。 -2. **数据库选型**:使用 SQLite,零部署成本,适合个人/小团队自托管。 -3. **密钥安全**:AccessKey Secret 使用 AES-256-GCM 加密,密钥通过 `APP_ENCRYPTION_KEY` 环境变量注入。 -4. **API 调用代理**:所有阿里云 VOD API 调用均通过后端代理,前端不接触密钥。 -5. **权限模型**:简单双角色(admin / readonly),admin 可修改账号与执行删除/更新,readonly 仅可查看与下载。 +1. **原生 macOS 应用**:使用 SwiftUI + SwiftData,目标上架 Mac App Store。 +2. **直接调用阿里云 API**:不依赖后端服务,VODClient actor 直接发起 HTTPS 请求并自实现签名。 +3. **本地持久化**:SwiftData 存储账号、日志、下载任务。 +4. **下载目录**:由用户通过 NSOpenPanel 选择,符合 App Sandbox 要求。 +5. **文件名处理**:使用视频标题 + 清晰度组合命名,自动替换非法字符,避免乱码。 +6. **Web 版废弃**:`apps/web` 与 `apps/server` 保留作为历史参考,不再维护。 ## 已知问题 / 待优化 -1. 下载地址获取是同步串行循环,后续应改为异步队列并增加进度条。 -2. 前端构建产物单文件较大(>1MB),可考虑按路由懒加载。 -3. 尚未接入真实阿里云 VOD 环境进行端到端验证,字段映射可能需要根据实际 API 响应微调。 -4. 单元测试覆盖较少,目前仅覆盖 crypto 工具函数。 -5. 分类树目前只读取一层子分类,未做递归展开。 +1. 当前 `swift run` 可编译但 GUI 需在真实 macOS 桌面会话运行。 +2. 下载进度目前只有开始/完成两种状态,未实现实时百分比(URLSessionDownloadTask 进度回调未接入)。 +3. 尚未接入真实阿里云账号进行端到端验证。 +4. 缺少单元测试。 +5. AccessKey Secret 目前明文存储在 SwiftData,后续应迁移到 Keychain。 +6. 需要创建正式的 Xcode 工程以配置 Signing、Bundle ID,满足 App Store 提交要求。 ## 后续规划 -### v0.2.0 - -- [ ] 真实 VOD 环境联调与字段校准 -- [ ] 批量任务异步队列与进度展示 -- [ ] 标签管理 -- [ ] 更完善的错误提示 - ### v0.3.0 -- [ ] 支持 STS 临时凭证 -- [ ] 下载任务导出 CSV -- [ ] 前端路由懒加载优化 +- [ ] 接入真实账号联调 +- [ ] 下载进度实时显示 +- [ ] Keychain 存储 AccessKey Secret +- [ ] 分类树展示 + +### v0.4.0 + +- [ ] 批量删除视频 +- [ ] 视频详情页面 +- [ ] 操作日志写入(目前只展示,下载/删除未写入日志) ### v1.0.0 +- [ ] Xcode 工程与签名配置 +- [ ] TestFlight / Mac App Store 提交 - [ ] 中英文国际化 - [ ] 完善测试覆盖 -- [ ] 发布正式 Release ## 部署注意 -- 生产环境必须设置 `JWT_SECRET` 和 `APP_ENCRYPTION_KEY`。 -- 默认管理员账号 `admin/admin` 仅用于首次登录,务必修改。 -- SQLite 数据库文件位于 `apps/server/data/vod-manager.db`,Docker 部署时通过 volume 持久化。 +- macOS 应用默认使用 SwiftData 自动管理的数据库路径(Application Support)。 +- App Store 版本必须开启 Sandbox,使用 `VODManager.entitlements` 中的配置。 +- 用户选择下载目录后,应用才拥有该目录的写入权限。 diff --git a/README.md b/README.md index 7494081..de277f9 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,60 @@ -# 阿里云 VOD 媒体库管理器 +# 火炬 VOD 管理器 -一个**轻量、开源、可自托管**的阿里云视频点播(VOD)媒体资源管理后台,解决官方控制台操作繁琐、批量处理能力弱的问题。 +一个**轻量、开源**的阿里云视频点播(VOD)媒体资源管理工具,解决官方控制台批量下载、删除操作繁琐的问题。 + +> **注意**:Web 版已废弃,当前主要维护 **macOS 原生 SwiftUI 版本**,目标上架 Mac App Store。 ## 功能特性 -- 视频库快速查看、搜索、筛选(标题、分类、标签、状态、时间) -- 批量获取视频播放/下载地址,支持选择清晰度 -- 批量删除视频 -- 多账号/多 Region 切换管理 -- 内置登录与角色控制(管理员 / 只读) +- 视频库快速查看、搜索 +- 批量下载到用户指定目录 +- 下载文件自动使用视频标题重命名 +- 多账号 / 多 Region 切换管理 +- 下载任务历史 - 操作日志审计 -- 单 Docker 镜像一键部署 +- 浏览器下载与下载工具(aria2 / wget)两种导出方式 ## 技术栈 -- **前端**:React 18 + TypeScript + Vite + Ant Design 5 + Zustand -- **后端**:Node.js 20 + Express + TypeScript + SQLite -- **阿里云 SDK**:`@alicloud/pop-core` -- **部署**:Docker + Docker Compose +- **macOS 客户端**:Swift 6 + SwiftUI + SwiftData +- **阿里云 API**:自实现 POP 签名,直接调用 VOD OpenAPI +- **网络**:URLSession +- **持久化**:SwiftData(SQLite) -## 快速开始 +## 项目结构 -### 方式一:Docker Compose(推荐) - -```bash -git clone https://github.com/your-org/Aliyun-VOD-Media-Library-Manager.git -cd Aliyun-VOD-Media-Library-Manager/docker -cp .env.example .env -# 编辑 .env,设置 JWT_SECRET 等 -sudo docker-compose up -d +``` +. +├── apps/ +│ ├── macos/ # macOS 原生 SwiftUI 应用(当前维护重点) +│ ├── web/ # React 前端(已废弃,保留作为历史参考) +│ └── server/ # Express 后端(已废弃,保留作为历史参考) +├── docs/ # 文档 +├── PRD.md # 产品需求文档 +├── README.md +└── AGENTS.md ``` -访问 http://localhost:3001,默认账号密码 `admin` / `admin`。 - -### 方式二:源码运行 +## macOS 版快速开始 ```bash -# 安装依赖 -npm install - -# 启动后端(端口 3001) -npm run dev -w apps/server - -# 新终端启动前端(端口 5173) -npm run dev -w apps/web +cd apps/macos/VODManager +swift build +swift run ``` -## 配置说明 +或在 Xcode 中打开 `Package.swift`。 -关键环境变量: +## App Store 上架准备 -| 变量 | 说明 | 默认值 | -| --- | --- | --- | -| `JWT_SECRET` | JWT 签名密钥,生产环境必须修改 | `vod-manager-dev-secret-change-me` | -| `APP_ENCRYPTION_KEY` | AccessKey Secret 加密密钥 | 空(明文存储,会告警) | -| `DEFAULT_ADMIN_USERNAME` | 默认管理员账号 | `admin` | -| `DEFAULT_ADMIN_PASSWORD` | 默认管理员密码 | `admin` | -| `DB_PATH` | SQLite 数据库路径 | `./data/vod-manager.db` | +1. 在 Xcode 中配置 Signing & Capabilities +2. 使用 `apps/macos/VODManager/VODManager.entitlements` 中的沙盒配置 +3. 配置 Bundle Identifier 和 Team +4. 提交 Mac App Store 审核 ## 阿里云 RAM 最小权限 -建议为本工具单独创建 RAM 子用户,并授予最小权限: +建议为本工具单独创建 RAM 子用户: ```json { @@ -82,31 +76,6 @@ npm run dev -w apps/web } ``` -如果只需要只读能力,请移除 `DeleteVideo` 和 `UpdateVideoInfo`。 - -## 项目结构 - -``` -. -├── apps/ -│ ├── web/ # React 前端 -│ └── server/ # Express 后端 + SQLite -├── docker/ -│ ├── Dockerfile -│ └── docker-compose.yml -├── docs/ # 部署与使用文档 -├── PRD.md # 产品需求文档 -└── README.md -``` - -## 贡献指南 - -欢迎 Issue 和 Pull Request。请确保: - -1. 代码通过 `npm run lint` -2. 核心逻辑补充测试 -3. 提交信息清晰说明改动 - ## License [MIT](./LICENSE) diff --git a/apps/macos/VODManager/.gitignore b/apps/macos/VODManager/.gitignore new file mode 100644 index 0000000..7825beb --- /dev/null +++ b/apps/macos/VODManager/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/ diff --git a/apps/macos/VODManager/Package.swift b/apps/macos/VODManager/Package.swift new file mode 100644 index 0000000..4699b45 --- /dev/null +++ b/apps/macos/VODManager/Package.swift @@ -0,0 +1,16 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "VODManager", + platforms: [.macOS(.v14)], + products: [ + .executable(name: "VODManager", targets: ["VODManager"]), + ], + targets: [ + .executableTarget( + name: "VODManager", + path: "Sources/VODManager" + ), + ] +) diff --git a/apps/macos/VODManager/README.md b/apps/macos/VODManager/README.md new file mode 100644 index 0000000..f2ea58a --- /dev/null +++ b/apps/macos/VODManager/README.md @@ -0,0 +1,57 @@ +# 火炬 VOD 管理器 - macOS 原生版 + +基于 SwiftUI + Swift Package Manager 的阿里云 VOD 桌面管理客户端。 + +## 特性 + +- 原生 macOS SwiftUI 界面 +- 直接调用阿里云 VOD OpenAPI,无需 Node 后端 +- 多账号管理、切换 +- 视频列表、搜索、详情 +- 批量下载到用户指定目录 +- 自定义下载文件名(自动清理非法字符) +- 下载任务历史 +- 操作日志 + +## 技术栈 + +- Swift 6 + SwiftUI +- GRDB(SQLite) +- CryptoKit(阿里云 POP 签名) +- URLSession(网络请求与下载) + +## 开发要求 + +- macOS 14+ +- Xcode 15+ +- Swift 5.9+ + +## 本地运行 + +```bash +cd apps/macos/VODManager +swift build +swift run +``` + +或在 Xcode 中打开 `Package.swift`。 + +## App Store 上架准备 + +1. 在 Xcode 中创建正式的 macOS App 工程(或打开 Swift Package)。 +2. 配置 Signing & Capabilities: + - App Sandbox 开启 + - 启用 entitlements 文件 `VODManager.entitlements` + - Team 与 Bundle Identifier +3. 提交 Mac App Store 审核。 + +### Entitlements 说明 + +- `com.apple.security.app-sandbox`:沙盒必须开启 +- `com.apple.security.network.client`:访问阿里云 API +- `com.apple.security.files.user-selected.read-write`:用户选择下载目录 +- `com.apple.security.files.downloads.read-write`:写入 Downloads 目录 + +## 与 Web 版的关系 + +Web 版(`apps/web` + `apps/server`)已废弃,不再维护。所有新功能在 macOS 原生版中实现。 diff --git a/apps/macos/VODManager/Sources/VODManager/Models/Models.swift b/apps/macos/VODManager/Sources/VODManager/Models/Models.swift new file mode 100644 index 0000000..14aef77 --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Models/Models.swift @@ -0,0 +1,142 @@ +import Foundation +import SwiftData + +@Model +class Account { + @Attribute(.unique) var id: UUID + var name: String + var accessKeyId: String + var accessKeySecret: String + var region: String + var endpoint: String? + var isActive: Bool + var createdAt: Date + var updatedAt: Date + + init( + id: UUID = UUID(), + name: String, + accessKeyId: String, + accessKeySecret: String, + region: String, + endpoint: String? = nil, + isActive: Bool = false, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.name = name + self.accessKeyId = accessKeyId + self.accessKeySecret = accessKeySecret + self.region = region + self.endpoint = endpoint + self.isActive = isActive + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +@Model +class OperationLog { + @Attribute(.unique) var id: UUID + var action: String + var targetType: String + var targetIds: String + var details: String? + var createdAt: Date + + init( + id: UUID = UUID(), + action: String, + targetType: String, + targetIds: String, + details: String? = nil, + createdAt: Date = Date() + ) { + self.id = id + self.action = action + self.targetType = targetType + self.targetIds = targetIds + self.details = details + self.createdAt = createdAt + } +} + +@Model +class DownloadTask { + @Attribute(.unique) var id: UUID + var videoId: String + var title: String + var definition: String + var sourceURL: String + var localPath: String? + var statusRaw: String + var progress: Double + var errorMessage: String? + var createdAt: Date + + var status: DownloadStatus { + get { DownloadStatus(rawValue: statusRaw) ?? .pending } + set { statusRaw = newValue.rawValue } + } + + init( + id: UUID = UUID(), + videoId: String, + title: String, + definition: String, + sourceURL: String, + localPath: String? = nil, + status: DownloadStatus = .pending, + progress: Double = 0, + errorMessage: String? = nil, + createdAt: Date = Date() + ) { + self.id = id + self.videoId = videoId + self.title = title + self.definition = definition + self.sourceURL = sourceURL + self.localPath = localPath + self.statusRaw = status.rawValue + self.progress = progress + self.errorMessage = errorMessage + self.createdAt = createdAt + } +} + +enum DownloadStatus: String, Codable { + case pending, downloading, completed, failed +} + +struct VideoItem: Identifiable, Codable, Equatable { + var id: String { videoId } + var videoId: String + var title: String + var description: String? + var duration: Double? + var size: Int64? + var coverURL: String? + var status: String? + var cateId: Int? + var cateName: String? + var tags: String? + var creationTime: String? + var modificationTime: String? +} + +struct PlayInfo: Codable, Equatable { + var playURL: String + var definition: String + var duration: String? + var size: Int64? + var format: String? +} + +struct CategoryItem: Identifiable, Codable, Equatable { + var id: Int { cateId } + var cateId: Int + var cateName: String + var level: Int? + var parentId: Int? +} diff --git a/apps/macos/VODManager/Sources/VODManager/Services/AccountStore.swift b/apps/macos/VODManager/Sources/VODManager/Services/AccountStore.swift new file mode 100644 index 0000000..571dc6f --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Services/AccountStore.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftData + +@MainActor +class AccountStore: ObservableObject { + static let shared = AccountStore() + + @Published var accounts: [Account] = [] + @Published var activeAccount: Account? + + private init() {} + + func load() async { + accounts = await DatabaseManager.shared.allAccounts() + activeAccount = await DatabaseManager.shared.activeAccount() + if let account = activeAccount { + await VODClient.shared.setActiveAccount(account) + } + } + + func addAccount(_ account: Account) async { + await DatabaseManager.shared.save(account) + if account.isActive { + await DatabaseManager.shared.clearActiveAccounts() + account.isActive = true + await DatabaseManager.shared.save(account) + } + await load() + } + + func updateAccount(_ account: Account) async { + await DatabaseManager.shared.save(account) + await load() + } + + func deleteAccount(_ account: Account) async { + await DatabaseManager.shared.delete(account) + await load() + } + + func switchActive(_ account: Account) async { + await DatabaseManager.shared.clearActiveAccounts() + account.isActive = true + account.updatedAt = Date() + await DatabaseManager.shared.save(account) + await VODClient.shared.setActiveAccount(account) + await load() + } +} diff --git a/apps/macos/VODManager/Sources/VODManager/Services/DatabaseManager.swift b/apps/macos/VODManager/Sources/VODManager/Services/DatabaseManager.swift new file mode 100644 index 0000000..6b30378 --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Services/DatabaseManager.swift @@ -0,0 +1,72 @@ +import Foundation +import SwiftData + +@MainActor +class DatabaseManager { + static let shared = DatabaseManager() + + let container: ModelContainer + + private init() { + let schema = Schema([Account.self, OperationLog.self, DownloadTask.self]) + let config = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: false + ) + do { + container = try ModelContainer(for: schema, configurations: [config]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + } + + var context: ModelContext { + ModelContext(container) + } + + func insert(_ model: any PersistentModel) async { + let ctx = context + ctx.insert(model) + try? ctx.save() + } + + func delete(_ model: any PersistentModel) async { + let ctx = context + ctx.delete(model) + try? ctx.save() + } + + func save(_ model: any PersistentModel) async { + let ctx = context + ctx.insert(model) + try? ctx.save() + } + + func allAccounts() async -> [Account] { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) + return (try? context.fetch(descriptor)) ?? [] + } + + func activeAccount() async -> Account? { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.isActive == true }) + return try? context.fetch(descriptor).first + } + + func clearActiveAccounts() async { + let all = await allAccounts() + for account in all { + account.isActive = false + } + try? context.save() + } + + func allLogs() async -> [OperationLog] { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) + return (try? context.fetch(descriptor)) ?? [] + } + + func allDownloadTasks() async -> [DownloadTask] { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt, order: .reverse)]) + return (try? context.fetch(descriptor)) ?? [] + } +} diff --git a/apps/macos/VODManager/Sources/VODManager/Services/DownloadManager.swift b/apps/macos/VODManager/Sources/VODManager/Services/DownloadManager.swift new file mode 100644 index 0000000..f44320c --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Services/DownloadManager.swift @@ -0,0 +1,114 @@ +import Foundation +import SwiftData + +@MainActor +class DownloadManager: ObservableObject { + static let shared = DownloadManager() + + @Published var tasks: [DownloadTask] = [] + + private var session: URLSession! + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 300 + config.timeoutIntervalForResource = 86400 + session = URLSession(configuration: config) + } + + func loadTasks() async { + tasks = await DatabaseManager.shared.allDownloadTasks() + } + + func download( + video: VideoItem, + playInfo: PlayInfo, + to directory: URL, + filename: String? = nil + ) async -> DownloadTask { + let safeTitle = sanitizeFilename(video.title) + let ext = (playInfo.format ?? "mp4").lowercased() + let name = filename ?? "\(safeTitle)_\(playInfo.definition).\(ext)" + let destination = directory.appendingPathComponent(name) + + let task = DownloadTask( + videoId: video.videoId, + title: video.title, + definition: playInfo.definition, + sourceURL: playInfo.playURL, + localPath: destination.path, + status: .pending, + progress: 0, + errorMessage: nil, + createdAt: Date() + ) + + await DatabaseManager.shared.insert(task) + await loadTasks() + + guard let url = URL(string: playInfo.playURL) else { + task.status = .failed + task.errorMessage = "Invalid URL" + await DatabaseManager.shared.save(task) + await loadTasks() + return task + } + + task.status = .downloading + await DatabaseManager.shared.save(task) + + do { + let (tempURL, _) = try await session.download(from: url) + if FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.removeItem(at: destination) + } + try FileManager.default.moveItem(at: tempURL, to: destination) + task.status = .completed + task.progress = 1 + } catch { + task.status = .failed + task.errorMessage = error.localizedDescription + } + + await DatabaseManager.shared.save(task) + await loadTasks() + return task + } + + func batchDownload( + items: [(video: VideoItem, playInfo: PlayInfo)], + to directory: URL, + maxConcurrent: Int = 2 + ) async { + await withTaskGroup(of: Void.self) { group in + var iterator = items.makeIterator() + var running = 0 + + while let item = iterator.next() { + group.addTask { + _ = await self.download(video: item.video, playInfo: item.playInfo, to: directory) + } + running += 1 + + if running >= maxConcurrent { + await group.next() + running -= 1 + } + } + + while running > 0 { + await group.next() + running -= 1 + } + } + } + + private func sanitizeFilename(_ filename: String) -> String { + let invalidCharacters = CharacterSet(charactersIn: "/\\?%*|:\"<>") + return filename + .components(separatedBy: invalidCharacters) + .joined(separator: "_") + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "..", with: ".") + } +} diff --git a/apps/macos/VODManager/Sources/VODManager/Services/VODClient.swift b/apps/macos/VODManager/Sources/VODManager/Services/VODClient.swift new file mode 100644 index 0000000..0c2a64c --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Services/VODClient.swift @@ -0,0 +1,232 @@ +import Foundation + +enum VODClientError: Error { + case noActiveAccount + case invalidResponse + case apiError(String) + case networkError(Error) +} + +actor VODClient { + static let shared = VODClient() + + private let session = URLSession.shared + private var activeAccount: Account? + + func setActiveAccount(_ account: Account) { + self.activeAccount = account + } + + private func endpoint() -> String { + guard let account = activeAccount else { return "" } + if let endpoint = account.endpoint, !endpoint.isEmpty { + return endpoint + } + return "vod.\(account.region).aliyuncs.com" + } + + private func baseParameters() -> [String: String] { + guard let account = activeAccount else { return [:] } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return [ + "AccessKeyId": account.accessKeyId, + "Format": "JSON", + "SignatureNonce": UUID().uuidString, + "Timestamp": formatter.string(from: Date()), + "Version": "2017-03-21", + ] + } + + private func requestURL(action: String, parameters: [String: String]) async throws -> URL { + guard let account = activeAccount else { + throw VODClientError.noActiveAccount + } + + var params = baseParameters() + params.merge(parameters) { _, new in new } + params["Action"] = action + + let signature = try VODSignature.sign(parameters: params, accessKeySecret: account.accessKeySecret) + params["Signature"] = signature + + let host = endpoint() + var components = URLComponents() + components.scheme = "https" + components.host = host + components.path = "/" + components.queryItems = params.sorted(by: { $0.key < $1.key }).map { + URLQueryItem(name: $0.key, value: $0.value) + } + + guard let url = components.url else { + throw VODClientError.invalidResponse + } + return url + } + + func request(action: String, parameters: [String: String]) async throws -> T { + let url = try await requestURL(action: action, parameters: parameters) + let (data, response) = try await session.data(from: url) + + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode >= 400 { + if let errorResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = errorResponse["Message"] as? String { + throw VODClientError.apiError(message) + } + throw VODClientError.apiError("HTTP \(httpResponse.statusCode)") + } + + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } + + func searchMedia( + keyword: String? = nil, + cateId: Int? = nil, + pageNo: Int = 1, + pageSize: Int = 10, + sortBy: String = "CreationTime", + sortOrder: String = "Desc" + ) async throws -> SearchMediaResponse { + var params: [String: String] = [ + "SearchType": "video", + "Fields": "Title,CoverURL,CreationTime,Status,Duration,Size,CateId,CateName,Tags", + "PageNo": "\(pageNo)", + "PageSize": "\(min(pageSize, 100))", + "SortBy": sortBy, + "SortOrder": sortOrder, + ] + + var matchParts: [String] = [] + if let keyword = keyword, !keyword.isEmpty { + matchParts.append("Title='\(escapeMatch(keyword))'") + } + if let cateId = cateId { + matchParts.append("CateId=\(cateId)") + } + if !matchParts.isEmpty { + params["Match"] = matchParts.joined(separator: " and ") + } + + return try await request(action: "SearchMedia", parameters: params) + } + + func getVideoInfo(videoId: String) async throws -> GetVideoInfoResponse { + return try await request(action: "GetVideoInfo", parameters: ["VideoId": videoId]) + } + + func getPlayInfo(videoId: String, definition: String? = nil) async throws -> GetPlayInfoResponse { + var params: [String: String] = ["VideoId": videoId] + if let definition = definition { + params["Definition"] = definition + } + return try await request(action: "GetPlayInfo", parameters: params) + } + + func deleteVideos(videoIds: [String]) async throws { + let _: EmptyResponse = try await request(action: "DeleteVideo", parameters: ["VideoIds": videoIds.joined(separator: ",")]) + } + + func updateVideoInfo( + videoId: String, + title: String? = nil, + description: String? = nil, + tags: String? = nil, + cateId: Int? = nil + ) async throws { + var params: [String: String] = ["VideoId": videoId] + if let title = title { params["Title"] = title } + if let description = description { params["Description"] = description } + if let tags = tags { params["Tags"] = tags } + if let cateId = cateId { params["CateId"] = "\(cateId)" } + let _: EmptyResponse = try await request(action: "UpdateVideoInfo", parameters: params) + } + + func getCategories() async throws -> GetCategoriesResponse { + return try await request(action: "GetCategories", parameters: ["PageSize": "100"]) + } + + private func escapeMatch(_ value: String) -> String { + return value.replacingOccurrences(of: "'", with: "\\'") + } +} + +struct SearchMediaResponse: Decodable { + var Total: Int? + var PageNo: Int? + var PageSize: Int? + var MediaList: [MediaItem]? + + var videos: [VideoItem] { + (MediaList ?? []).map { $0.video } + } +} + +struct MediaItem: Decodable { + var Video: MediaVideo? + var MediaId: String? + var CreationTime: String? + + var video: VideoItem { + let v = Video ?? MediaVideo() + return VideoItem( + videoId: v.VideoId ?? MediaId ?? "", + title: v.Title ?? "", + description: nil, + duration: v.Duration, + size: v.Size, + coverURL: v.CoverURL, + status: v.Status, + cateId: v.CateId, + cateName: v.CateName, + tags: v.Tags, + creationTime: v.CreationTime ?? CreationTime, + modificationTime: v.ModificationTime + ) + } +} + +struct MediaVideo: Decodable { + var VideoId: String? + var Title: String? + var Duration: Double? + var Size: Int64? + var CoverURL: String? + var Status: String? + var CateId: Int? + var CateName: String? + var Tags: String? + var CreationTime: String? + var ModificationTime: String? +} + +struct GetVideoInfoResponse: Decodable { + var Video: MediaVideo +} + +struct GetPlayInfoResponse: Decodable { + var PlayInfoList: PlayInfoList? + + var playInfos: [PlayInfo] { + PlayInfoList?.PlayInfo ?? [] + } +} + +struct PlayInfoList: Decodable { + var PlayInfo: [PlayInfo] +} + +struct GetCategoriesResponse: Decodable { + var SubCategory: CategoryWrapper? + + var categories: [CategoryItem] { + SubCategory?.SubCategory ?? [] + } +} + +struct CategoryWrapper: Decodable { + var SubCategory: [CategoryItem] +} + +struct EmptyResponse: Decodable {} diff --git a/apps/macos/VODManager/Sources/VODManager/Services/VODSignature.swift b/apps/macos/VODManager/Sources/VODManager/Services/VODSignature.swift new file mode 100644 index 0000000..56df256 --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Services/VODSignature.swift @@ -0,0 +1,47 @@ +import Foundation +import CryptoKit + +enum VODSignatureError: Error { + case invalidKey + case encodingFailed +} + +struct VODSignature { + static func sign( + method: String = "GET", + parameters: [String: String], + accessKeySecret: String + ) throws -> String { + var params = parameters + params["SignatureMethod"] = "HMAC-SHA1" + params["SignatureVersion"] = "1.0" + + let sortedKeys = params.keys.sorted() + let canonicalQuery = sortedKeys + .compactMap { key -> String? in + guard let value = params[key] else { return nil } + return "\(percentEncode(key))=\(percentEncode(value))" + } + .joined(separator: "&") + + let stringToSign = "\(method)&%2F&\(percentEncode(canonicalQuery))" + + guard let keyData = "\(accessKeySecret)&".data(using: .utf8) else { + throw VODSignatureError.invalidKey + } + guard let messageData = stringToSign.data(using: .utf8) else { + throw VODSignatureError.encodingFailed + } + + let key = SymmetricKey(data: keyData) + let signature = HMAC.authenticationCode(for: messageData, using: key) + let signatureData = Data(signature) + return signatureData.base64EncodedString() + } + + private static func percentEncode(_ value: String) -> String { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-_.~") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } +} diff --git a/apps/macos/VODManager/Sources/VODManager/Utils/DateExtensions.swift b/apps/macos/VODManager/Sources/VODManager/Utils/DateExtensions.swift new file mode 100644 index 0000000..eddcb0a --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Utils/DateExtensions.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Date { + func iso8601String() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: self) + } +} diff --git a/apps/macos/VODManager/Sources/VODManager/VODManagerApp.swift b/apps/macos/VODManager/Sources/VODManager/VODManagerApp.swift new file mode 100644 index 0000000..1363755 --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/VODManagerApp.swift @@ -0,0 +1,17 @@ +import SwiftUI +import SwiftData + +@main +struct VODManagerApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .frame(minWidth: 1000, minHeight: 700) + } + .windowResizability(.contentSize) + .modelContainer(DatabaseManager.shared.container) + .commands { + CommandGroup(replacing: .newItem) { } + } + } +} diff --git a/apps/macos/VODManager/Sources/VODManager/Views/AccountListView.swift b/apps/macos/VODManager/Sources/VODManager/Views/AccountListView.swift new file mode 100644 index 0000000..1c85f06 --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Views/AccountListView.swift @@ -0,0 +1,135 @@ +import SwiftUI +import SwiftData + +struct AccountListView: View { + @EnvironmentObject var accountStore: AccountStore + @Query(sort: \Account.createdAt, order: .reverse) private var accounts: [Account] + @State private var showForm = false + @State private var editingAccount: Account? + + var body: some View { + VStack { + HStack { + Spacer() + Button("添加账号") { + editingAccount = nil + showForm = true + } + .buttonStyle(.borderedProminent) + } + .padding() + + List(accounts) { account in + HStack { + VStack(alignment: .leading) { + Text(account.name) + .font(.headline) + Text("\(account.accessKeyId) / \(account.region)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if account.isActive { + Text("当前使用") + .font(.caption) + .foregroundStyle(.green) + } else { + Button("切换") { + Task { await accountStore.switchActive(account) } + } + .buttonStyle(.borderless) + } + + Button("编辑") { + editingAccount = account + showForm = true + } + .buttonStyle(.borderless) + + Button("删除") { + Task { await accountStore.deleteAccount(account) } + } + .buttonStyle(.borderless) + .foregroundStyle(.red) + } + .padding(.vertical, 4) + } + } + .sheet(isPresented: $showForm) { + AccountFormView(account: editingAccount) + } + } +} + +struct AccountFormView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var accountStore: AccountStore + @Query(sort: \Account.createdAt, order: .reverse) private var accounts: [Account] + + var account: Account? + + @State private var name = "" + @State private var accessKeyId = "" + @State private var accessKeySecret = "" + @State private var region = "cn-shanghai" + @State private var endpoint = "" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(account == nil ? "添加账号" : "编辑账号") + .font(.title2) + + Form { + TextField("名称", text: $name) + TextField("AccessKey ID", text: $accessKeyId) + SecureField(account == nil ? "AccessKey Secret" : "AccessKey Secret(留空则不修改)", text: $accessKeySecret) + TextField("Region", text: $region) + TextField("Endpoint(可选)", text: $endpoint) + } + + HStack { + Spacer() + Button("取消") { + dismiss() + } + Button("保存") { + Task { await save() } + } + .buttonStyle(.borderedProminent) + } + } + .padding() + .frame(minWidth: 400) + .onAppear { + if let account = account { + name = account.name + accessKeyId = account.accessKeyId + region = account.region + endpoint = account.endpoint ?? "" + } + } + } + + private func save() async { + let secret = accessKeySecret.isEmpty ? (account?.accessKeySecret ?? "") : accessKeySecret + let newAccount = Account( + id: account?.id ?? UUID(), + name: name, + accessKeyId: accessKeyId, + accessKeySecret: secret, + region: region, + endpoint: endpoint.isEmpty ? nil : endpoint, + isActive: account?.isActive ?? (accounts.isEmpty ? true : false), + createdAt: account?.createdAt ?? Date(), + updatedAt: Date() + ) + if account != nil { + await accountStore.updateAccount(newAccount) + } else { + await accountStore.addAccount(newAccount) + } + dismiss() + } +} diff --git a/apps/macos/VODManager/Sources/VODManager/Views/ContentView.swift b/apps/macos/VODManager/Sources/VODManager/Views/ContentView.swift new file mode 100644 index 0000000..06beec1 --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Views/ContentView.swift @@ -0,0 +1,58 @@ +import SwiftUI +import SwiftData + +struct ContentView: View { + @StateObject private var accountStore = AccountStore.shared + + var body: some View { + NavigationSplitView { + SidebarView() + .frame(minWidth: 180) + } detail: { + Text("请在左侧选择功能") + .font(.title2) + .foregroundStyle(.secondary) + } + .environmentObject(accountStore) + .task { + await accountStore.load() + } + } +} + +struct SidebarView: View { + @EnvironmentObject var accountStore: AccountStore + + var body: some View { + List { + Section("媒体库") { + NavigationLink(destination: VideoListView()) { + Label("视频库", systemImage: "film") + } + } + + Section("管理") { + NavigationLink(destination: AccountListView()) { + Label("账号配置", systemImage: "key") + } + NavigationLink(destination: DownloadTaskListView()) { + Label("下载任务", systemImage: "arrow.down.circle") + } + NavigationLink(destination: LogListView()) { + Label("操作日志", systemImage: "doc.text") + } + } + + if let account = accountStore.activeAccount { + Section("当前账号") { + Label(account.name, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(account.region) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("火炬VOD管理器") + } +} diff --git a/apps/macos/VODManager/Sources/VODManager/Views/DownloadPanelView.swift b/apps/macos/VODManager/Sources/VODManager/Views/DownloadPanelView.swift new file mode 100644 index 0000000..00706b3 --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Views/DownloadPanelView.swift @@ -0,0 +1,201 @@ +import SwiftUI + +struct DownloadPanelView: View { + let items: [(video: VideoItem, playInfos: [PlayInfo])] + + @Environment(\.dismiss) private var dismiss + @StateObject private var downloadManager = DownloadManager.shared + @State private var selectedDefinitions: [String: String] = [:] + @State private var downloadDirectory: URL? + @State private var isDownloading = false + @State private var showToolsHelp = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("批量下载") + .font(.title2) + + HStack { + Text("下载目录:") + if let dir = downloadDirectory { + Text(dir.path) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text("未选择") + .foregroundStyle(.secondary) + } + Spacer() + Button("选择目录…") { + selectDirectory() + } + } + + List { + ForEach(items, id: \.video.videoId) { item in + HStack { + Text(item.video.title) + .lineLimit(1) + Spacer() + Picker("清晰度", selection: Binding( + get: { selectedDefinitions[item.video.videoId] ?? item.playInfos.first?.definition ?? "" }, + set: { selectedDefinitions[item.video.videoId] = $0 } + )) { + ForEach(item.playInfos, id: \.definition) { info in + Text("\(info.definition) - \(formatSize(info.size))").tag(info.definition) + } + } + .frame(width: 200) + } + } + } + + HStack { + Button("使用下载工具") { + showToolsHelp = true + } + .disabled(isDownloading) + + Spacer() + + Button("取消") { + dismiss() + } + .disabled(isDownloading) + + Button("浏览器下载") { + Task { await startBrowserDownloads() } + } + .disabled(downloadDirectory == nil || isDownloading) + .buttonStyle(.borderedProminent) + } + } + .padding() + .frame(minWidth: 600, minHeight: 400) + .task { + var defs: [String: String] = [:] + for item in items { + if let first = item.playInfos.first { + defs[item.video.videoId] = first.definition + } + } + selectedDefinitions = defs + } + .sheet(isPresented: $showToolsHelp) { + DownloadToolsHelpView(items: items, selectedDefinitions: selectedDefinitions) + } + } + + private func selectDirectory() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = "选择" + if panel.runModal() == .OK, let url = panel.url { + downloadDirectory = url + } + } + + private func startBrowserDownloads() async { + guard let directory = downloadDirectory else { return } + isDownloading = true + + var tasks: [(video: VideoItem, playInfo: PlayInfo)] = [] + for item in items { + guard let definition = selectedDefinitions[item.video.videoId], + let playInfo = item.playInfos.first(where: { $0.definition == definition }) else { + continue + } + tasks.append((item.video, playInfo)) + } + + await downloadManager.batchDownload(items: tasks, to: directory) + isDownloading = false + dismiss() + } + + private func formatSize(_ bytes: Int64?) -> String { + guard let bytes = bytes else { return "-" } + let units = ["B", "KB", "MB", "GB", "TB"] + var size = Double(bytes) + var i = 0 + while size >= 1024 && i < units.count - 1 { + size /= 1024 + i += 1 + } + return String(format: "%.2f %@", size, units[i]) + } +} + +struct DownloadToolsHelpView: View { + let items: [(video: VideoItem, playInfos: [PlayInfo])] + let selectedDefinitions: [String: String] + @Environment(\.dismiss) private var dismiss + + var selectedURLs: [String] { + items.compactMap { item -> String? in + guard let definition = selectedDefinitions[item.video.videoId] else { return nil } + return item.playInfos.first(where: { $0.definition == definition })?.playURL + } + } + + var wgetCommand: String { + selectedURLs.map { "wget \"\($0)\"" }.joined(separator: "\n") + } + + var aria2Command: String { + selectedURLs.map { "aria2c \"\($0)\"" }.joined(separator: "\n") + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("使用下载工具") + .font(.title2) + + Text("浏览器对批量下载限制较多,推荐使用 aria2 或 wget 等命令行工具。先点击下方按钮复制全部链接,再粘贴到终端执行。") + .foregroundStyle(.secondary) + + HStack { + Button("复制全部链接") { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(selectedURLs.joined(separator: "\n"), forType: .string) + } + Spacer() + Button("关闭") { + dismiss() + } + } + + Text("aria2 示例(推荐)") + .font(.headline) + Text("aria2 支持断点续传、多线程下载,适合大文件。") + .font(.caption) + .foregroundStyle(.secondary) + ScrollView { + Text(aria2Command) + .font(.system(.caption, design: .monospaced)) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.textBackgroundColor)) + .cornerRadius(6) + } + .frame(maxHeight: 120) + + Text("wget 示例") + .font(.headline) + ScrollView { + Text(wgetCommand) + .font(.system(.caption, design: .monospaced)) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.textBackgroundColor)) + .cornerRadius(6) + } + .frame(maxHeight: 120) + } + .padding() + .frame(minWidth: 500, minHeight: 400) + } +} diff --git a/apps/macos/VODManager/Sources/VODManager/Views/DownloadTaskListView.swift b/apps/macos/VODManager/Sources/VODManager/Views/DownloadTaskListView.swift new file mode 100644 index 0000000..88b3088 --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Views/DownloadTaskListView.swift @@ -0,0 +1,34 @@ +import SwiftUI +import SwiftData + +struct DownloadTaskListView: View { + @Query(sort: \DownloadTask.createdAt, order: .reverse) private var tasks: [DownloadTask] + + var body: some View { + List(tasks) { task in + HStack { + VStack(alignment: .leading) { + Text(task.title) + .lineLimit(1) + Text("\(task.definition) · \(task.status.rawValue)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if task.status == .downloading { + ProgressView(value: task.progress) + .frame(width: 120) + } + + if let path = task.localPath { + Button("打开") { + NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "") + } + .buttonStyle(.borderless) + } + } + } + } +} diff --git a/apps/macos/VODManager/Sources/VODManager/Views/LogListView.swift b/apps/macos/VODManager/Sources/VODManager/Views/LogListView.swift new file mode 100644 index 0000000..513277d --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Views/LogListView.swift @@ -0,0 +1,30 @@ +import SwiftUI +import SwiftData + +struct LogListView: View { + @Query(sort: \OperationLog.createdAt, order: .reverse) private var logs: [OperationLog] + + var body: some View { + List(logs) { log in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(log.action) + .font(.headline) + Spacer() + Text(log.createdAt, style: .date) + .font(.caption) + .foregroundStyle(.secondary) + } + Text("目标:\(log.targetIds)") + .font(.caption) + .foregroundStyle(.secondary) + if let details = log.details { + Text(details) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + } +} diff --git a/apps/macos/VODManager/Sources/VODManager/Views/VideoListView.swift b/apps/macos/VODManager/Sources/VODManager/Views/VideoListView.swift new file mode 100644 index 0000000..d6691f9 --- /dev/null +++ b/apps/macos/VODManager/Sources/VODManager/Views/VideoListView.swift @@ -0,0 +1,196 @@ +import SwiftUI + +struct VideoListView: View { + @State private var videos: [VideoItem] = [] + @State private var selectedVideos: Set = [] + @State private var keyword: String = "" + @State private var isLoading = false + @State private var total = 0 + @State private var pageNo = 1 + @State private var pageSize = 10 + @State private var errorMessage: String? + @State private var showDownloadPanel = false + @State private var downloadResults: [(video: VideoItem, playInfos: [PlayInfo])] = [] + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + TextField("搜索标题 / 视频 ID", text: $keyword) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 200) + + Button("搜索") { + pageNo = 1 + Task { await fetch() } + } + .keyboardShortcut(.return, modifiers: [.command]) + + Button("重置") { + keyword = "" + pageNo = 1 + Task { await fetch() } + } + + Spacer() + + Button("批量下载") { + Task { await prepareDownload() } + } + .disabled(selectedVideos.isEmpty || isLoading) + + Button("批量删除") { + Task { await batchDelete() } + } + .disabled(selectedVideos.isEmpty) + .buttonStyle(.borderedProminent) + .tint(.red) + } + .padding() + + if let error = errorMessage { + Text(error) + .foregroundStyle(.red) + .padding(.horizontal) + } + + Table(of: VideoItem.self, selection: $selectedVideos) { + TableColumn("标题") { video in + HStack { + AsyncImage(url: video.coverURL.flatMap(URL.init)) { image in + image.resizable().scaledToFill() + } placeholder: { + Color.gray + } + .frame(width: 60, height: 40) + .cornerRadius(4) + + Text(video.title) + .lineLimit(2) + } + } + .width(min: 200, ideal: 300) + + TableColumn("视频 ID") { video in + Text(video.videoId) + .font(.caption) + .foregroundStyle(.secondary) + } + .width(min: 120, ideal: 180) + + TableColumn("时长") { video in + Text(formatDuration(video.duration)) + } + .width(min: 60, ideal: 80) + + TableColumn("大小") { video in + Text(formatSize(video.size)) + } + .width(min: 80, ideal: 100) + + TableColumn("状态") { video in + Text(video.status ?? "-") + } + .width(min: 60, ideal: 80) + + TableColumn("创建时间") { video in + Text(video.creationTime ?? "-") + } + .width(min: 120, ideal: 160) + } rows: { + ForEach(videos) { video in + TableRow(video) + } + } + .frame(minHeight: 300) + + HStack { + Text("共 \(total) 条,已选 \(selectedVideos.count) 个") + .foregroundStyle(.secondary) + Spacer() + Button("上一页") { + pageNo -= 1 + Task { await fetch() } + } + .disabled(pageNo <= 1) + Text("第 \(pageNo) 页") + Button("下一页") { + pageNo += 1 + Task { await fetch() } + } + .disabled(pageNo * pageSize >= total) + } + .padding() + } + .task { + await fetch() + } + .sheet(isPresented: $showDownloadPanel) { + DownloadPanelView(items: downloadResults) + } + } + + private func fetch() async { + isLoading = true + errorMessage = nil + do { + let response = try await VODClient.shared.searchMedia( + keyword: keyword.isEmpty ? nil : keyword, + pageNo: pageNo, + pageSize: pageSize + ) + videos = response.videos + total = response.Total ?? 0 + pageNo = response.PageNo ?? pageNo + pageSize = response.PageSize ?? pageSize + } catch { + errorMessage = "加载失败:\(error.localizedDescription)" + } + isLoading = false + } + + private func batchDelete() async { + let ids = Array(selectedVideos) + do { + try await VODClient.shared.deleteVideos(videoIds: ids) + selectedVideos.removeAll() + await fetch() + } catch { + errorMessage = "删除失败:\(error.localizedDescription)" + } + } + + private func prepareDownload() async { + isLoading = true + var results: [(video: VideoItem, playInfos: [PlayInfo])] = [] + for video in videos where selectedVideos.contains(video.videoId) { + do { + let response = try await VODClient.shared.getPlayInfo(videoId: video.videoId) + results.append((video, response.playInfos)) + } catch { + results.append((video, [])) + } + } + downloadResults = results + showDownloadPanel = true + isLoading = false + } + + private func formatDuration(_ seconds: Double?) -> String { + guard let seconds = seconds else { return "-" } + let m = Int(seconds) / 60 + let s = Int(seconds) % 60 + return "\(m):\(String(format: "%02d", s))" + } + + private func formatSize(_ bytes: Int64?) -> String { + guard let bytes = bytes else { return "-" } + let units = ["B", "KB", "MB", "GB", "TB"] + var size = Double(bytes) + var i = 0 + while size >= 1024 && i < units.count - 1 { + size /= 1024 + i += 1 + } + return String(format: "%.2f %@", size, units[i]) + } +} diff --git a/apps/macos/VODManager/VODManager.entitlements b/apps/macos/VODManager/VODManager.entitlements new file mode 100644 index 0000000..0186f20 --- /dev/null +++ b/apps/macos/VODManager/VODManager.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + +