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
@@ -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])
}
}