- Swift 6 + SwiftUI + SwiftData 直接调用阿里云 VOD API - 自实现 POP 签名,无需 Node 后端 - 视频列表、搜索、批量下载到用户选择目录 - 下载文件自动使用视频标题重命名 - 浏览器下载与下载工具(aria2/wget)两种方式 - Web 版标记为废弃,README/AGENTS/MEMORY 更新
This commit is contained in:
@@ -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 变更时,需考虑现有用户数据迁移。
|
||||
|
||||
@@ -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` 中的配置。
|
||||
- 用户选择下载目录后,应用才拥有该目录的写入权限。
|
||||
|
||||
@@ -1,66 +1,60 @@
|
||||
# 阿里云 VOD 媒体库管理器
|
||||
# 火炬 VOD 管理器
|
||||
|
||||
一个**轻量、开源、可自托管**的阿里云视频点播(VOD)媒体资源管理后台,解决官方控制台操作繁琐、批量处理能力弱的问题。
|
||||
一个**轻量、开源**的阿里云视频点播(VOD)媒体资源管理工具,解决官方控制台批量下载、删除操作繁琐的问题。
|
||||
|
||||
> **注意**:Web 版已废弃,当前主要维护 **macOS 原生 SwiftUI 版本**,目标上架 Mac App Store。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 视频库快速查看、搜索、筛选(标题、分类、标签、状态、时间)
|
||||
- 批量获取视频播放/下载地址,支持选择清晰度
|
||||
- 批量删除视频
|
||||
- 视频库快速查看、搜索
|
||||
- 批量下载到用户指定目录
|
||||
- 下载文件自动使用视频标题重命名
|
||||
- 多账号 / 多 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)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/
|
||||
@@ -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"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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 原生版中实现。
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<Account>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
func activeAccount() async -> Account? {
|
||||
let descriptor = FetchDescriptor<Account>(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<OperationLog>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
func allDownloadTasks() async -> [DownloadTask] {
|
||||
let descriptor = FetchDescriptor<DownloadTask>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
|
||||
return (try? context.fetch(descriptor)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -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: ".")
|
||||
}
|
||||
}
|
||||
@@ -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<T: Decodable>(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 {}
|
||||
@@ -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<Insecure.SHA1>.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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
func iso8601String() -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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管理器")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VideoListView: View {
|
||||
@State private var videos: [VideoItem] = []
|
||||
@State private var selectedVideos: Set<String> = []
|
||||
@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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user