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
> 本项目(阿里云 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
- **持久化**SwiftDataSQLite
> 历史版本(已废弃):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 变更时,需考虑现有用户数据迁移
+49 -42
View File
@@ -4,68 +4,75 @@
## 当前状态
- **版本**v0.1.0MVP
- **阶段**基础功能已完成,前后端均可构建运行
- **最后更新**2026-06-29
- **版本**v0.2.0macOS 原生版 MVP
- **阶段**已废弃 Web 版,macOS SwiftUI 原生版可构建运行
- **最后更新**2026-06-30
## 已完成的里程碑
### MVPv0.1.0
### v0.1.0Web 版,已废弃
- [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.0macOS 原生版)
- [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` 中的配置
- 用户选择下载目录后,应用才拥有该目录的写入权限
+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
- **后端**Node.js 20 + Express + TypeScript + SQLite
- **阿里云 SDK**`@alicloud/pop-core`
- **部署**Docker + Docker Compose
- **macOS 客户端**Swift 6 + SwiftUI + SwiftData
- **阿里云 API**:自实现 POP 签名,直接调用 VOD OpenAPI
- **网络**URLSession
- **持久化**SwiftDataSQLite
## 快速开始
## 项目结构
### 方式一: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)
+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>