feat: 新增 macOS 原生 SwiftUI 客户端
CI / lint-and-build (push) Has been cancelled

- Swift 6 + SwiftUI + SwiftData 直接调用阿里云 VOD API

- 自实现 POP 签名,无需 Node 后端

- 视频列表、搜索、批量下载到用户选择目录

- 下载文件自动使用视频标题重命名

- 浏览器下载与下载工具(aria2/wget)两种方式

- Web 版标记为废弃,README/AGENTS/MEMORY 更新
This commit is contained in:
2026-06-30 08:38:43 +06:00
parent 50e5a4182a
commit e8c3e4644e
21 changed files with 1557 additions and 176 deletions
+42 -67
View File
@@ -1,33 +1,34 @@
# AGENTS.md # AGENTS.md
> 本项目(阿里云 VOD 媒体库管理器)的代理开发指南。 > 本项目(火炬 VOD 管理器)的代理开发指南。
## 项目背景 ## 项目背景
一个轻量、开源、可自托管的阿里云视频点播(VOD)媒体资源管理后台。核心目标是解决阿里云官方 VOD 控制台在查看、批量下载、批量删除大量视频时操作繁琐的问题。 一个轻量、开源的阿里云视频点播(VOD)媒体资源管理工具,核心目标是解决阿里云官方 VOD 控制台在查看、批量下载、批量删除大量视频时操作繁琐的问题。
- 名称:火炬 VOD 管理器
- 许可证:MIT - 许可证:MIT
- 用户群体:开发者、内容运营人员 - 用户群体:开发者、内容运营人员
- 部署方式:Docker / 源码运行 - 部署方式:macOS 原生应用(目标上架 Mac App Store
## 技术栈 ## 技术栈
- **端**React 18 + TypeScript + Vite + Ant Design 5 + Zustand - **客户端**Swift 6 + SwiftUI + SwiftData
- **后端**Node.js 20 + Express + TypeScript + SQLite - **阿里云 API**:自实现 POP 签名,直接调用 VOD OpenAPI
- **阿里云 SDK**`@alicloud/pop-core` - **网络**URLSession
- **容器化**Docker + Docker Compose - **持久化**SwiftDataSQLite
> 历史版本(已废弃):React 18 + Express + SQLite 的 Web 版保留在 `apps/web` 和 `apps/server`,不再维护。
## 目录结构 ## 目录结构
``` ```
. .
├── apps/ ├── apps/
│ ├── web/ # React 前端 │ ├── macos/ # macOS 原生 SwiftUI 应用
│ └── server/ # Express 后端 │ └── VODManager/
├── docker/ │ ├── web/ # React 前端(已废弃,历史参考)
── Dockerfile ── server/ # Express 后端(已废弃,历史参考)
│ ├── docker-compose.yml
│ └── .env.example
├── docs/ # 部署与使用文档 ├── docs/ # 部署与使用文档
├── PRD.md # 产品需求文档 ├── PRD.md # 产品需求文档
├── README.md ├── README.md
@@ -38,74 +39,48 @@
### 代码风格 ### 代码风格
- 使用 TypeScript,严格模式开启 - 使用 Swift 6,严格并发检查
- 后端统一使用 `asyncHandler` 包装异步路由处理器,避免未捕获异常导致进程崩溃 - SwiftData 模型类使用 `@Model`,避免在并发任务中直接传递 `@Model` 实例
- 后端数据库查询结果使用 camelCase 别名与 TypeScript 接口保持一致 - UI 使用 SwiftUI,状态管理使用 `@StateObject` / `@ObservedObject` / `@Query`
- 前端使用函数组件 + Hooks,状态管理使用 Zustand - 阿里云 API 调用统一封装在 `VODClient` actor 中
- API 响应统一格式:`{ code: number, data: T, message?: string }` - 下载任务使用 `URLSessionDownloadTask`,文件名自动清理非法字符
### 安全要求 ### 安全要求
- AccessKey Secret 必须加密存储(`APP_ENCRYPTION_KEY`),生产环境必须设置 - AccessKey Secret 存储在 SwiftData 中,建议后续接入 Keychain 加密存储
- JWT 密钥生产环境必须修改,默认密钥不允许用于生产 - 阿里云 API 调用直接从客户端发起,不经过第三方后端
- 阿里云 API 调用仅在后端进行,禁止前端直接持有密钥。
- 建议用户创建最小权限 RAM 子用户。 - 建议用户创建最小权限 RAM 子用户。
### 环境变量 ### 常用命令
后端关键配置见 `apps/server/.env.example`
- `PORT`:服务端端口
- `JWT_SECRET`JWT 签名密钥
- `APP_ENCRYPTION_KEY`AccessKey Secret 加密密钥
- `DEFAULT_ADMIN_USERNAME/PASSWORD`:默认管理员账号
- `DB_PATH`SQLite 数据库路径
## 常用命令
```bash ```bash
# 安装依赖 # 构建 macOS 应用
npm install cd apps/macos/VODManager
swift build
# 开发启动(同时启动前后端) # 运行
npm run dev cd apps/macos/VODManager
swift run
# 单独启动后端 # 在 Xcode 中打开
npm run dev -w apps/server open apps/macos/VODManager/Package.swift
# 单独启动前端
npm run dev -w apps/web
# 代码检查
npm run lint
# 测试
npm run test
# 生产构建
npm run build
# 生产运行
npm run start -w apps/server
``` ```
## 后端路由约定 ## App Store 注意事项
- `/api/auth`:登录与当前用户 - 必须开启 App Sandbox。
- `/api/accounts`:阿里云账号配置(需管理员权限修改) - 使用 `com.apple.security.network.client` 访问阿里云 API。
- `/api/videos`:视频列表、详情、批量下载/删除/更新 - 使用 `com.apple.security.files.user-selected.read-write` 让用户选择下载目录。
- `/api/categories`VOD 分类树 - 默认下载到应用沙盒内部,提供「导出」功能让用户移动到外部目录。
- `/api/logs`:操作日志
## 数据库 ## 数据库模型
- `users`:用户表(id, username, password_hash, role, created_at - `Account`:阿里云账号配置
- `accounts`:阿里云账号表(access_key_id, access_key_secret, region, endpoint, is_active, ... - `OperationLog`:操作日志
- `operation_logs`:操作日志表(action, target_type, target_ids, details, ... - `DownloadTask`:下载任务历史
## 贡献注意事项 ## 贡献注意事项
1. 修改前后端代码后,必须跑通 `npm run lint``npm run test` 1. macOS 修改后必须能通过 `swift build`
2. 后端新增接口建议同步写入 `docs/api.md`(如存在) 2. 提交信息使用中文,描述清晰
3. 涉及数据库 schema 变更时,需考虑现有部署的兼容性 3. 涉及 SwiftData schema 变更时,需考虑现有用户数据迁移
4. 提交信息使用中文,描述清晰。
+49 -42
View File
@@ -4,68 +4,75 @@
## 当前状态 ## 当前状态
- **版本**v0.1.0MVP - **版本**v0.2.0macOS 原生版 MVP
- **阶段**基础功能已完成,前后端均可构建运行 - **阶段**已废弃 Web 版,macOS SwiftUI 原生版可构建运行
- **最后更新**2026-06-29 - **最后更新**2026-06-30
## 已完成的里程碑 ## 已完成的里程碑
### MVPv0.1.0 ### v0.1.0Web 版,已废弃
- [x] 前后端项目初始化 - [x] React + Express 前后端项目
- [x] SQLite 数据库与默认管理员账号 - [x] JWT 登录、SQLite 持久化
- [x] JWT 登录认证与管理员/只读角色 - [x] VOD 账号配置与 API 代理
- [x] 阿里云 VOD 账号配置(多账号、切换、Secret 加密) - [x] 视频列表、批量下载/删除
- [x] 视频列表、搜索、筛选
- [x] 视频详情查看 ### v0.2.0macOS 原生版)
- [x] 批量获取下载地址(支持指定清晰度)
- [x] 批量删除视频 - [x] SwiftUI macOS 项目结构
- [x] 批量更新视频元信息 - [x] 自实现阿里云 POP 签名(CryptoKit HMAC-SHA1
- [x] VOD 分类树读取 - [x] 直接调用 VOD OpenAPI,无需 Node 后端
- [x] 操作日志记录 - [x] SwiftData 持久化(Account / OperationLog / DownloadTask
- [x] Docker + Docker Compose 部署配置 - [x] 视频列表、搜索
- [x] README 与部署文档 - [x] 批量下载到用户选择目录
- [x] GitHub Actions CI 配置 - [x] 下载文件自动重命名(清理非法字符)
- [x] 浏览器下载与下载工具(aria2 / wget)两种方式
- [x] 下载任务历史
- [x] 操作日志
- [x] App Sandbox entitlements 配置
## 架构决策 ## 架构决策
1. **前后端分离**React 前端 + Express 后端,生产环境由后端 serve 前端静态资源 1. **原生 macOS 应用**:使用 SwiftUI + SwiftData,目标上架 Mac App Store
2. **数据库选型**:使用 SQLite,零部署成本,适合个人/小团队自托管 2. **直接调用阿里云 API**:不依赖后端服务,VODClient actor 直接发起 HTTPS 请求并自实现签名
3. **密钥安全**AccessKey Secret 使用 AES-256-GCM 加密,密钥通过 `APP_ENCRYPTION_KEY` 环境变量注入 3. **本地持久化**SwiftData 存储账号、日志、下载任务
4. **API 调用代理**:所有阿里云 VOD API 调用均通过后端代理,前端不接触密钥 4. **下载目录**:由用户通过 NSOpenPanel 选择,符合 App Sandbox 要求
5. **权限模型**简单双角色(admin / readonly),admin 可修改账号与执行删除/更新,readonly 仅可查看与下载 5. **文件名处理**使用视频标题 + 清晰度组合命名,自动替换非法字符,避免乱码
6. **Web 版废弃**`apps/web``apps/server` 保留作为历史参考,不再维护。
## 已知问题 / 待优化 ## 已知问题 / 待优化
1. 下载地址获取是同步串行循环,后续应改为异步队列并增加进度条 1. 当前 `swift run` 可编译但 GUI 需在真实 macOS 桌面会话运行
2. 前端构建产物单文件较大(>1MB),可考虑按路由懒加载 2. 下载进度目前只有开始/完成两种状态,未实现实时百分比(URLSessionDownloadTask 进度回调未接入)
3. 尚未接入真实阿里云 VOD 环境进行端到端验证,字段映射可能需要根据实际 API 响应微调 3. 尚未接入真实阿里云账号进行端到端验证
4. 单元测试覆盖较少,目前仅覆盖 crypto 工具函数 4. 缺少单元测试。
5. 分类树目前只读取一层子分类,未做递归展开 5. AccessKey Secret 目前明文存储在 SwiftData,后续应迁移到 Keychain
6. 需要创建正式的 Xcode 工程以配置 Signing、Bundle ID,满足 App Store 提交要求。
## 后续规划 ## 后续规划
### v0.2.0
- [ ] 真实 VOD 环境联调与字段校准
- [ ] 批量任务异步队列与进度展示
- [ ] 标签管理
- [ ] 更完善的错误提示
### v0.3.0 ### v0.3.0
- [ ] 支持 STS 临时凭证 - [ ] 接入真实账号联调
- [ ] 下载任务导出 CSV - [ ] 下载进度实时显示
- [ ] 前端路由懒加载优化 - [ ] Keychain 存储 AccessKey Secret
- [ ] 分类树展示
### v0.4.0
- [ ] 批量删除视频
- [ ] 视频详情页面
- [ ] 操作日志写入(目前只展示,下载/删除未写入日志)
### v1.0.0 ### v1.0.0
- [ ] Xcode 工程与签名配置
- [ ] TestFlight / Mac App Store 提交
- [ ] 中英文国际化 - [ ] 中英文国际化
- [ ] 完善测试覆盖 - [ ] 完善测试覆盖
- [ ] 发布正式 Release
## 部署注意 ## 部署注意
- 生产环境必须设置 `JWT_SECRET``APP_ENCRYPTION_KEY` - macOS 应用默认使用 SwiftData 自动管理的数据库路径(Application Support
- 默认管理员账号 `admin/admin` 仅用于首次登录,务必修改 - App Store 版本必须开启 Sandbox,使用 `VODManager.entitlements` 中的配置
- SQLite 数据库文件位于 `apps/server/data/vod-manager.db`Docker 部署时通过 volume 持久化 - 用户选择下载目录后,应用才拥有该目录的写入权限
+36 -67
View File
@@ -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 - **macOS 客户端**Swift 6 + SwiftUI + SwiftData
- **后端**Node.js 20 + Express + TypeScript + SQLite - **阿里云 API**:自实现 POP 签名,直接调用 VOD OpenAPI
- **阿里云 SDK**`@alicloud/pop-core` - **网络**URLSession
- **部署**Docker + Docker Compose - **持久化**SwiftDataSQLite
## 快速开始 ## 项目结构
### 方式一:Docker Compose(推荐) ```
.
```bash ├── apps/
git clone https://github.com/your-org/Aliyun-VOD-Media-Library-Manager.git │ ├── macos/ # macOS 原生 SwiftUI 应用(当前维护重点)
cd Aliyun-VOD-Media-Library-Manager/docker │ ├── web/ # React 前端(已废弃,保留作为历史参考)
cp .env.example .env │ └── server/ # Express 后端(已废弃,保留作为历史参考)
# 编辑 .env,设置 JWT_SECRET 等 ├── docs/ # 文档
sudo docker-compose up -d ├── PRD.md # 产品需求文档
├── README.md
└── AGENTS.md
``` ```
访问 http://localhost:3001,默认账号密码 `admin` / `admin` ## macOS 版快速开始
### 方式二:源码运行
```bash ```bash
# 安装依赖 cd apps/macos/VODManager
npm install swift build
swift run
# 启动后端(端口 3001
npm run dev -w apps/server
# 新终端启动前端(端口 5173
npm run dev -w apps/web
``` ```
## 配置说明 或在 Xcode 中打开 `Package.swift`
关键环境变量: ## App Store 上架准备
| 变量 | 说明 | 默认值 | 1. 在 Xcode 中配置 Signing & Capabilities
| --- | --- | --- | 2. 使用 `apps/macos/VODManager/VODManager.entitlements` 中的沙盒配置
| `JWT_SECRET` | JWT 签名密钥,生产环境必须修改 | `vod-manager-dev-secret-change-me` | 3. 配置 Bundle Identifier 和 Team
| `APP_ENCRYPTION_KEY` | AccessKey Secret 加密密钥 | 空(明文存储,会告警) | 4. 提交 Mac App Store 审核
| `DEFAULT_ADMIN_USERNAME` | 默认管理员账号 | `admin` |
| `DEFAULT_ADMIN_PASSWORD` | 默认管理员密码 | `admin` |
| `DB_PATH` | SQLite 数据库路径 | `./data/vod-manager.db` |
## 阿里云 RAM 最小权限 ## 阿里云 RAM 最小权限
建议为本工具单独创建 RAM 子用户,并授予最小权限 建议为本工具单独创建 RAM 子用户:
```json ```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 ## License
[MIT](./LICENSE) [MIT](./LICENSE)
+7
View File
@@ -0,0 +1,7 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/
+16
View File
@@ -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"
),
]
)
+57
View File
@@ -0,0 +1,57 @@
# 火炬 VOD 管理器 - macOS 原生版
基于 SwiftUI + Swift Package Manager 的阿里云 VOD 桌面管理客户端。
## 特性
- 原生 macOS SwiftUI 界面
- 直接调用阿里云 VOD OpenAPI,无需 Node 后端
- 多账号管理、切换
- 视频列表、搜索、详情
- 批量下载到用户指定目录
- 自定义下载文件名(自动清理非法字符)
- 下载任务历史
- 操作日志
## 技术栈
- Swift 6 + SwiftUI
- GRDBSQLite
- 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>