迁移:SPM → Xcode 构建,String Catalog 替代 .strings,清理 web/server/docker 废弃代码

- 项目从 SPM (Sources/VODManager/) 迁移到 Xcode 构建系统 (VODManager.xcodeproj)
- 源码目录从 Sources/VODManager/ 迁移至 VODManager/VODManager/
- 用 Localizable.xcstrings 替代旧的 .lproj/Localizable.strings 文件
- 删除已废弃的 apps/web、apps/server、docker 目录及相关文档
- RTL 侧边栏修复:切语言时用 .id() 强制重建 NavigationSplitView
- 视频库无账号时不发起 API 请求和重试
- 无账号时显示 ContentUnavailableView 提示
This commit is contained in:
2026-06-30 15:46:06 +06:00
parent 05b247bed6
commit d94054dd1a
81 changed files with 3141 additions and 12522 deletions
-7
View File
@@ -1,7 +0,0 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/
-18
View File
@@ -1,18 +0,0 @@
// 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
@@ -1,57 +0,0 @@
# 火炬 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 原生版中实现。
@@ -1,114 +0,0 @@
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: ".")
}
}
@@ -1,100 +0,0 @@
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
@State private var showAccountPicker = false
var body: some View {
VStack(spacing: 0) {
List {
Section("媒体库") {
NavigationLink(destination: VideoListView()) {
Label("视频库", systemImage: "film")
}
}
Section("下载") {
NavigationLink(destination: DownloadTaskListView()) {
Label("下载任务", systemImage: "arrow.down.circle")
}
}
Section("管理") {
NavigationLink(destination: AccountListView()) {
Label("账号配置", systemImage: "key")
}
NavigationLink(destination: LogListView()) {
Label("操作日志", systemImage: "doc.text")
}
}
}
if let account = accountStore.activeAccount {
Divider()
VStack(spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(account.name)
.font(.headline)
.lineLimit(1)
Spacer()
}
HStack {
Text(account.region)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
if accountStore.accounts.count > 1 {
Menu {
ForEach(accountStore.accounts) { acct in
Button {
Task { await accountStore.switchActive(acct) }
} label: {
HStack {
Text(acct.name)
if acct.id == account.id {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Label("切换账号", systemImage: "arrow.triangle.2.circlepath")
.font(.caption)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(12)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 8)
.padding(.bottom, 8)
}
}
.navigationTitle("火炬VOD管理器")
}
}
@@ -1,34 +0,0 @@
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,342 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
3DBB047B2FF3BBD70086E8DC /* VODManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VODManager.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
3DBB047D2FF3BBD70086E8DC /* VODManager */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = VODManager;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
3DBB04782FF3BBD70086E8DC /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
3DBB04722FF3BBD70086E8DC = {
isa = PBXGroup;
children = (
3DBB047D2FF3BBD70086E8DC /* VODManager */,
3DBB047C2FF3BBD70086E8DC /* Products */,
);
sourceTree = "<group>";
};
3DBB047C2FF3BBD70086E8DC /* Products */ = {
isa = PBXGroup;
children = (
3DBB047B2FF3BBD70086E8DC /* VODManager.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
3DBB047A2FF3BBD70086E8DC /* VODManager */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3DBB04862FF3BBD80086E8DC /* Build configuration list for PBXNativeTarget "VODManager" */;
buildPhases = (
3DBB04772FF3BBD70086E8DC /* Sources */,
3DBB04782FF3BBD70086E8DC /* Frameworks */,
3DBB04792FF3BBD70086E8DC /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
3DBB047D2FF3BBD70086E8DC /* VODManager */,
);
name = VODManager;
packageProductDependencies = (
);
productName = VODManager;
productReference = 3DBB047B2FF3BBD70086E8DC /* VODManager.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
3DBB04732FF3BBD70086E8DC /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2660;
TargetAttributes = {
3DBB047A2FF3BBD70086E8DC = {
CreatedOnToolsVersion = 26.6;
};
};
};
buildConfigurationList = 3DBB04762FF3BBD70086E8DC /* Build configuration list for PBXProject "VODManager" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
ug,
);
mainGroup = 3DBB04722FF3BBD70086E8DC;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 3DBB047C2FF3BBD70086E8DC /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
3DBB047A2FF3BBD70086E8DC /* VODManager */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
3DBB04792FF3BBD70086E8DC /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
3DBB04772FF3BBD70086E8DC /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
3DBB04842FF3BBD80086E8DC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A4XHABX9V7;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.5;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
3DBB04852FF3BBD80086E8DC /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A4XHABX9V7;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.5;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
3DBB04872FF3BBD80086E8DC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = VODManager/VODManager.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = A4XHABX9V7;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.meshel.VODManager;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
3DBB04882FF3BBD80086E8DC /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = VODManager/VODManager.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = A4XHABX9V7;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.meshel.VODManager;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
3DBB04762FF3BBD70086E8DC /* Build configuration list for PBXProject "VODManager" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3DBB04842FF3BBD80086E8DC /* Debug */,
3DBB04852FF3BBD80086E8DC /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3DBB04862FF3BBD80086E8DC /* Build configuration list for PBXNativeTarget "VODManager" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3DBB04872FF3BBD80086E8DC /* Debug */,
3DBB04882FF3BBD80086E8DC /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 3DBB04732FF3BBD70086E8DC /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

@@ -0,0 +1,68 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
File diff suppressed because it is too large Load Diff
@@ -118,9 +118,9 @@ enum FileConflictStrategy: String, CaseIterable, Identifiable {
var label: String {
switch self {
case .skip: return "跳过已存在文件"
case .overwrite: return "覆盖已有文件"
case .rename: return "自动重命名(加序号)"
case .skip: return String(localized: "conflict.skip")
case .overwrite: return String(localized: "conflict.overwrite")
case .rename: return String(localized: "conflict.rename")
}
}
}
@@ -1,3 +1,4 @@
import Combine
import Foundation
import SwiftData
@@ -6,6 +6,7 @@ class DatabaseManager {
static let shared = DatabaseManager()
let container: ModelContainer
let context: ModelContext
private init() {
let schema = Schema([Account.self, OperationLog.self, DownloadTask.self])
@@ -18,28 +19,22 @@ class DatabaseManager {
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}
var context: ModelContext {
ModelContext(container)
context = ModelContext(container)
}
func insert(_ model: any PersistentModel) async {
let ctx = context
ctx.insert(model)
try? ctx.save()
context.insert(model)
try? context.save()
}
func delete(_ model: any PersistentModel) async {
let ctx = context
ctx.delete(model)
try? ctx.save()
context.delete(model)
try? context.save()
}
func save(_ model: any PersistentModel) async {
let ctx = context
ctx.insert(model)
try? ctx.save()
context.insert(model)
try? context.save()
}
func allAccounts() async -> [Account] {
@@ -69,4 +64,21 @@ class DatabaseManager {
let descriptor = FetchDescriptor<DownloadTask>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
return (try? context.fetch(descriptor)) ?? []
}
func deleteDownloadTask(_ task: DownloadTask) async {
context.delete(task)
try? context.save()
}
func clearCompletedDownloadTasks() async {
let descriptor = FetchDescriptor<DownloadTask>(
predicate: #Predicate { $0.statusRaw == "completed" || $0.statusRaw == "failed" }
)
if let tasks = try? context.fetch(descriptor) {
for task in tasks {
context.delete(task)
}
try? context.save()
}
}
}
@@ -0,0 +1,222 @@
import Combine
import Foundation
import SwiftData
// MARK: -
private class DownloadHelper: NSObject, URLSessionDownloadDelegate {
private var continuation: CheckedContinuation<URL, Error>?
private let progressHandler: @Sendable (Double) -> Void
private var didComplete = false
init(progressHandler: @escaping @Sendable (Double) -> Void) {
self.progressHandler = progressHandler
}
func download(from url: URL) async throws -> URL {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 300
config.timeoutIntervalForResource = 86400
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
self.continuation = continuation
let task = session.downloadTask(with: url)
task.resume()
}
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
guard !didComplete else { return }
didComplete = true
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("vod_dl_\(UUID().uuidString)")
do {
try FileManager.default.moveItem(at: location, to: tempURL)
continuation?.resume(returning: tempURL)
} catch {
continuation?.resume(throwing: error)
}
continuation = nil
session.invalidateAndCancel()
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
guard totalBytesExpectedToWrite > 0 else { return }
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
progressHandler(progress)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard !didComplete else { return }
if let error {
didComplete = true
continuation?.resume(throwing: error)
continuation = nil
session.invalidateAndCancel()
}
}
}
// MARK: - DownloadManager
@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 batchDownload(
items: [(video: VideoItem, playInfo: PlayInfo)],
to directory: URL,
conflictStrategy: FileConflictStrategy = .skip,
maxConcurrent: Int = 2
) async {
await withTaskGroup(of: Void.self) { group in
for item in items {
group.addTask {
_ = await DownloadManager.downloadSingle(
video: item.video,
playInfo: item.playInfo,
to: directory,
conflictStrategy: conflictStrategy
)
}
}
await group.waitForAll()
}
await loadTasks()
}
func cancelAll() async {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
session.getTasksWithCompletionHandler { _, _, downloadTasks in
downloadTasks.forEach { $0.cancel() }
continuation.resume()
}
}
await loadTasks()
}
// MARK: - MainActor
nonisolated private static func downloadSingle(
video: VideoItem,
playInfo: PlayInfo,
to directory: URL,
conflictStrategy: FileConflictStrategy = .skip
) async -> DownloadTask {
let safeTitle = sanitizeFilename(video.title)
let ext = (playInfo.format ?? "mp4").lowercased()
let baseName = "\(safeTitle)_\(playInfo.definition)"
var name = "\(baseName).\(ext)"
var destination = directory.appendingPathComponent(name)
if FileManager.default.fileExists(atPath: destination.path) {
switch conflictStrategy {
case .skip:
let task = DownloadTask(
videoId: video.videoId,
title: video.title,
definition: playInfo.definition,
sourceURL: playInfo.playURL,
localPath: destination.path,
status: .completed,
progress: 1,
errorMessage: nil,
createdAt: Date()
)
await DatabaseManager.shared.insert(task)
return task
case .overwrite:
try? FileManager.default.removeItem(at: destination)
case .rename:
var counter = 1
while FileManager.default.fileExists(atPath: destination.path) {
name = "\(baseName)_\(counter).\(ext)"
destination = directory.appendingPathComponent(name)
counter += 1
}
}
}
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)
guard let url = URL(string: playInfo.playURL) else {
task.status = .failed
task.errorMessage = "无效的下载链接"
await DatabaseManager.shared.save(task)
return task
}
task.status = .downloading
await DatabaseManager.shared.save(task)
let helper = DownloadHelper { [task] progress in
let clamped = min(max(progress, 0), 1)
Task { @MainActor in
task.progress = clamped
}
}
do {
let tempURL = try await helper.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)
return task
}
nonisolated private static 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,44 @@
import SwiftUI
enum AppLanguage: String, CaseIterable, Identifiable {
case zhHans = "zh-Hans"
case en = "en"
case ug = "ug"
var id: String { rawValue }
var displayName: String {
switch self {
case .zhHans: return "简体中文"
case .en: return "English"
case .ug: return "ئۇيغۇرچە"
}
}
var layoutDirection: LayoutDirection {
switch self {
case .ug: return .rightToLeft
default: return .leftToRight
}
}
var locale: Locale {
Locale(identifier: rawValue)
}
}
extension AppLanguage {
static var current: AppLanguage {
let raw = UserDefaults.standard.string(forKey: "appLanguage") ?? ""
return AppLanguage(rawValue: raw) ?? Self.systemDefault
}
static var systemDefault: AppLanguage {
let lang = Locale.current.language.languageCode?.identifier ?? "zh-Hans"
return AppLanguage(rawValue: lang) ?? .zhHans
}
static func setCurrent(_ language: AppLanguage) {
UserDefaults.standard.set(language.rawValue, forKey: "appLanguage")
}
}
@@ -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>
@@ -11,7 +11,7 @@ struct VODManagerApp: App {
.frame(minWidth: 1000, minHeight: 700)
}
.windowResizability(.contentSize)
.modelContainer(DatabaseManager.shared.container)
.modelContext(DatabaseManager.shared.context)
.commands {
CommandGroup(replacing: .newItem) { }
}
@@ -12,7 +12,7 @@ struct AccountListView: View {
VStack {
HStack {
Spacer()
Button("添加账号") {
Button("account.add") {
showAddForm = true
}
.buttonStyle(.borderedProminent)
@@ -30,7 +30,7 @@ struct AccountListView: View {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("复制错误信息")
.help("error.copy_help")
Spacer()
}
.padding(.horizontal)
@@ -49,22 +49,22 @@ struct AccountListView: View {
Spacer()
if account.isActive {
Text("当前使用")
Text("account.active")
.font(.caption)
.foregroundStyle(.green)
} else {
Button("切换") {
Button("btn.switch") {
Task { await accountStore.switchActive(account) }
}
.buttonStyle(.borderless)
}
Button("编辑") {
Button("btn.edit") {
editingAccount = account
}
.buttonStyle(.borderless)
Button("删除") {
Button("btn.delete") {
Task { await accountStore.deleteAccount(account) }
}
.buttonStyle(.borderless)
@@ -97,37 +97,37 @@ struct AccountFormView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(account == nil ? "添加账号" : "编辑账号")
Text(account == nil ? "account.add_title" : "account.edit_title")
.font(.title2)
VStack(alignment: .leading, spacing: 12) {
Text("名称")
TextField("用于区分不同账号", text: $name)
Text("account.name")
TextField("account.name_placeholder", text: $name)
.textFieldStyle(.roundedBorder)
Text("AccessKey ID")
TextField("如 LTAI...", text: $accessKeyId)
Text("account.access_key_id")
TextField("account.access_key_id_placeholder", text: $accessKeyId)
.textFieldStyle(.roundedBorder)
Text(account == nil ? "AccessKey Secret" : "AccessKey Secret(留空则不修改)")
SecureField("如 8fKx...", text: $accessKeySecret)
Text(account == nil ? "account.access_key_secret" : "account.access_key_secret_edit")
SecureField("account.access_key_secret_placeholder", text: $accessKeySecret)
.textFieldStyle(.roundedBorder)
Text("Region")
TextField("如 cn-shanghai", text: $region)
Text("account.region")
TextField("account.region_placeholder", text: $region)
.textFieldStyle(.roundedBorder)
Text("Endpoint(可选)")
TextField("默认 vod.{region}.aliyuncs.com", text: $endpoint)
Text("account.endpoint")
TextField("account.endpoint_placeholder", text: $endpoint)
.textFieldStyle(.roundedBorder)
}
HStack {
Spacer()
Button("取消") {
Button("btn.cancel") {
dismiss()
}
Button("保存") {
Button("btn.save") {
Task { await save() }
}
.buttonStyle(.borderedProminent)
@@ -0,0 +1,121 @@
import SwiftUI
struct ContentView: View {
@StateObject private var accountStore = AccountStore.shared
@AppStorage("appLanguage") private var appLanguage: String = AppLanguage.systemDefault.rawValue
private var currentLanguage: AppLanguage {
AppLanguage(rawValue: appLanguage) ?? .zhHans
}
var body: some View {
SidebarView()
.environmentObject(accountStore)
.environment(\.locale, currentLanguage.locale)
.environment(\.layoutDirection, currentLanguage.layoutDirection)
.id(appLanguage)
.task {
await accountStore.load()
}
}
}
struct SidebarView: View {
@EnvironmentObject var accountStore: AccountStore
@AppStorage("appLanguage") private var appLanguage: String = AppLanguage.systemDefault.rawValue
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
List {
Section("sidebar.media") {
NavigationLink(destination: VideoListView()) {
Label("nav.video_library", systemImage: "film")
}
}
Section("sidebar.download") {
NavigationLink(destination: DownloadTaskListView()) {
Label("nav.download_tasks", systemImage: "arrow.down.circle")
}
}
Section("sidebar.manage") {
NavigationLink(destination: AccountListView()) {
Label("nav.account_config", systemImage: "key")
}
NavigationLink(destination: LogListView()) {
Label("nav.operation_logs", systemImage: "doc.text")
}
}
}
Divider()
HStack(spacing: 4) {
Text("🌐")
Picker("", selection: $appLanguage) {
ForEach(AppLanguage.allCases) { lang in
Text(lang.displayName).tag(lang.rawValue)
}
}
.pickerStyle(.menu)
.frame(width: 120)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
if let account = accountStore.activeAccount {
VStack(spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(account.name)
.font(.headline)
.lineLimit(1)
Spacer()
}
HStack {
Text(account.region)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
if accountStore.accounts.count > 1 {
Menu {
ForEach(accountStore.accounts) { acct in
Button {
Task { await accountStore.switchActive(acct) }
} label: {
HStack {
Text(acct.name)
if acct.id == account.id {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Label("account.switch_title", systemImage: "arrow.triangle.2.circlepath")
.font(.caption)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(12)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 8)
.padding(.bottom, 8)
}
}
.navigationTitle("app.title")
} detail: {
Text("detail.hint")
.font(.title2)
.foregroundStyle(.secondary)
}
}
}
@@ -9,24 +9,27 @@ struct DownloadPanelView: View {
@State private var downloadDirectory: URL?
@State private var isDownloading = false
@State private var errorMessage: String?
@State private var conflictStrategy: FileConflictStrategy = .skip
@State private var showConflictAlert = false
@State private var conflictingFiles: [String] = []
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("批量下载")
Text("download.title")
.font(.title2)
HStack {
Text("下载目录:")
Text("download.directory")
if let dir = downloadDirectory {
Text(dir.path)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("未选择")
Text("download.not_selected")
.foregroundStyle(.secondary)
}
Spacer()
Button("选择目录…") {
Button("download.select_directory") {
selectDirectory()
}
}
@@ -38,10 +41,10 @@ struct DownloadPanelView: View {
.lineLimit(1)
Spacer()
if item.playInfos.isEmpty {
Text("无可用播放地址")
Text("download.no_play_url")
.foregroundStyle(.secondary)
} else {
Picker("清晰度", selection: Binding(
Picker("download.definition", selection: Binding(
get: { selectedDefinitions[item.video.videoId] ?? item.playInfos.first?.definition ?? "" },
set: { selectedDefinitions[item.video.videoId] = $0 }
)) {
@@ -66,20 +69,29 @@ struct DownloadPanelView: View {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("复制错误信息")
.help("error.copy_help")
Spacer()
}
.padding(.horizontal)
}
HStack {
Picker("download.conflict_strategy", selection: $conflictStrategy) {
ForEach(FileConflictStrategy.allCases) { strategy in
Text(strategy.label).tag(strategy)
}
}
.frame(width: 220)
.disabled(isDownloading)
Spacer()
Button("取消") {
Button("btn.cancel") {
dismiss()
}
.disabled(isDownloading)
Button("开始下载") {
Button("download.start") {
Task { await startDownloads() }
}
.disabled(downloadDirectory == nil || isDownloading)
@@ -97,6 +109,23 @@ struct DownloadPanelView: View {
}
selectedDefinitions = defs
}
.alert("download.conflict_title", isPresented: $showConflictAlert) {
Button("download.conflict_skip") {
conflictStrategy = .skip
executeDownload()
}
Button("download.conflict_overwrite") {
conflictStrategy = .overwrite
executeDownload()
}
Button("download.conflict_rename") {
conflictStrategy = .rename
executeDownload()
}
Button("btn.cancel", role: .cancel) {}
} message: {
Text(verbatim: String(format: String(localized: "download.conflict_message"), conflictingFiles.joined(separator: "\n")))
}
}
private func selectDirectory() {
@@ -104,7 +133,7 @@ struct DownloadPanelView: View {
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "选择"
panel.prompt = String(localized: "picker.choose")
if panel.runModal() == .OK, let url = panel.url {
downloadDirectory = url
}
@@ -112,8 +141,37 @@ struct DownloadPanelView: View {
private func startDownloads() async {
guard let directory = downloadDirectory else { return }
let tasks = buildTaskList()
guard !tasks.isEmpty else { return }
let conflicts = findConflictingFiles(tasks: tasks, directory: directory)
if !conflicts.isEmpty {
conflictingFiles = conflicts
showConflictAlert = true
return
}
executeDownload()
}
private func executeDownload() {
guard let directory = downloadDirectory else { return }
isDownloading = true
let tasks = buildTaskList()
dismiss()
Task {
await downloadManager.batchDownload(
items: tasks,
to: directory,
conflictStrategy: conflictStrategy
)
}
}
private func buildTaskList() -> [(video: VideoItem, playInfo: PlayInfo)] {
var tasks: [(video: VideoItem, playInfo: PlayInfo)] = []
for item in items {
guard let definition = selectedDefinitions[item.video.videoId],
@@ -122,10 +180,33 @@ struct DownloadPanelView: View {
}
tasks.append((item.video, playInfo))
}
return tasks
}
await downloadManager.batchDownload(items: tasks, to: directory)
isDownloading = false
dismiss()
private func findConflictingFiles(
tasks: [(video: VideoItem, playInfo: PlayInfo)],
directory: URL
) -> [String] {
var conflicts: [String] = []
for (video, playInfo) in tasks {
let safeTitle = sanitizeFilename(video.title)
let ext = (playInfo.format ?? "mp4").lowercased()
let name = "\(safeTitle)_\(playInfo.definition).\(ext)"
let path = directory.appendingPathComponent(name)
if FileManager.default.fileExists(atPath: path.path) {
conflicts.append(name)
}
}
return conflicts
}
private func sanitizeFilename(_ filename: String) -> String {
let invalidCharacters = CharacterSet(charactersIn: "/\\?%*|:\"<>")
return filename
.components(separatedBy: invalidCharacters)
.joined(separator: "_")
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "..", with: ".")
}
private func formatSize(_ bytes: Int64?) -> String {
@@ -0,0 +1,119 @@
import SwiftUI
import SwiftData
struct DownloadTaskListView: View {
@EnvironmentObject var accountStore: AccountStore
@Query(sort: \DownloadTask.createdAt, order: .reverse) private var tasks: [DownloadTask]
var body: some View {
VStack(spacing: 0) {
if tasks.isEmpty {
ContentUnavailableView(
"download_task.empty_title",
systemImage: "arrow.down.circle",
description: Text("download_task.empty_desc")
)
} else {
HStack {
Text(verbatim: String(format: String(localized: "download_task.total"), tasks.count))
.foregroundStyle(.secondary)
Spacer()
Button("download_task.clear_completed") {
Task { await DatabaseManager.shared.clearCompletedDownloadTasks() }
}
.buttonStyle(.bordered)
}
.padding(.horizontal)
.padding(.vertical, 8)
List {
ForEach(tasks) { task in
DownloadTaskRow(task: task)
}
}
}
}
.navigationTitle("nav.download_tasks")
}
}
struct DownloadTaskRow: View {
let task: DownloadTask
@State private var showDeleteConfirm = false
var body: some View {
HStack {
statusIcon
VStack(alignment: .leading) {
Text(task.title)
.font(.headline)
}
Spacer()
statusText
if task.status == .completed {
Button("btn.open") {
if let path = task.localPath {
NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "")
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.vertical, 4)
.swipeActions(edge: .trailing) {
Button {
showDeleteConfirm = true
} label: {
Label("btn.delete", systemImage: "trash")
}
}
.alert("download_task.delete_confirm_title", isPresented: $showDeleteConfirm) {
Button("btn.cancel", role: .cancel) {}
Button("btn.delete", role: .destructive) {
Task { await DatabaseManager.shared.deleteDownloadTask(task) }
}
} message: {
Text(verbatim: String(format: String(localized: "download_task.delete_confirm_msg"), task.title))
}
}
@ViewBuilder
private var statusIcon: some View {
switch task.status {
case .pending:
Image(systemName: "clock.fill")
.foregroundColor(.gray)
case .downloading:
Image(systemName: "arrow.down.circle.fill")
.foregroundColor(.blue)
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
case .failed:
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(.red)
}
}
@ViewBuilder
private var statusText: some View {
switch task.status {
case .pending:
Text("download_task.status.pending")
case .downloading:
ProgressView(value: task.progress)
.frame(width: 80)
case .completed:
Text("download_task.status.completed")
case .failed:
VStack(alignment: .trailing) {
Text("download_task.status.failed")
Text(task.errorMessage ?? "")
.font(.caption2)
.foregroundStyle(.red)
}
}
}
}
@@ -15,7 +15,7 @@ struct LogListView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Text("目标:\(log.targetIds)")
Text(verbatim: String(format: String(localized: "log.target"), log.targetIds))
.font(.caption)
.foregroundStyle(.secondary)
if let details = log.details {
@@ -18,27 +18,33 @@ struct VideoListView: View {
private var pageSize: Int { pageSizes[pageSizeIndex] }
@State private var pageNo = 1
private var totalPages: Int { max(1, (filteredVideos.count + pageSize - 1) / pageSize) }
private var currentPageSelectedCount: Int {
let currentPageIds = Set(displayVideos.map(\.videoId))
return selectedVideos.intersection(currentPageIds).count
}
var body: some View {
if accountStore.activeAccount == nil {
ContentUnavailableView(
"error.no_active_account",
systemImage: "person.slash",
description: Text("account.add")
)
} else {
content
}
}
@ViewBuilder
private var content: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
TextField("搜索本地缓存标题", text: $keyword)
TextField("video.search_placeholder", text: $keyword)
.textFieldStyle(.roundedBorder)
.frame(minWidth: 200)
.onChange(of: keyword) { _, _ in
applyFilter()
}
Button("搜索") {
Button("btn.search") {
applyFilter()
}
.keyboardShortcut(.return, modifiers: [.command])
Button("重置") {
Button("btn.reset") {
keyword = ""
applyFilter()
}
@@ -51,14 +57,14 @@ struct VideoListView: View {
Image(systemName: "arrow.clockwise")
}
.disabled(isLoading)
.help("从服务器刷新")
.help("video.refresh_help")
Button("批量下载") {
Button("video.batch_download") {
Task { await prepareDownload() }
}
.disabled(selectedVideos.isEmpty || isLoading)
Button("全选") {
Button("video.select_all") {
if selectedVideos.count == filteredVideos.count {
selectedVideos.removeAll()
} else {
@@ -66,7 +72,7 @@ struct VideoListView: View {
}
}
Button("批量删除") {
Button("video.batch_delete") {
Task { await batchDelete() }
}
.disabled(selectedVideos.isEmpty)
@@ -86,7 +92,7 @@ struct VideoListView: View {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("复制错误信息")
.help("error.copy_help")
Spacer()
}
.padding(.horizontal)
@@ -118,7 +124,7 @@ struct VideoListView: View {
}
.width(min: 30, ideal: 30, max: 30)
TableColumn("标题") { video in
TableColumn("video.table.title") { video in
HStack {
AsyncImage(url: video.coverURL.flatMap(URL.init)) { image in
image.resizable().scaledToFill()
@@ -134,29 +140,29 @@ struct VideoListView: View {
}
.width(min: 200, ideal: 300)
TableColumn("视频 ID") { video in
TableColumn("video.table.video_id") { video in
Text(video.videoId)
.font(.caption)
.foregroundStyle(.secondary)
}
.width(min: 120, ideal: 180)
TableColumn("时长") { video in
TableColumn("video.table.duration") { video in
Text(formatDuration(video.duration))
}
.width(min: 60, ideal: 80)
TableColumn("大小") { video in
TableColumn("video.table.size") { video in
Text(formatSize(video.size))
}
.width(min: 80, ideal: 100)
TableColumn("状态") { video in
TableColumn("video.table.status") { video in
Text(video.status ?? "-")
}
.width(min: 60, ideal: 80)
TableColumn("创建时间") { video in
TableColumn("video.table.created_at") { video in
Text(video.creationTime ?? "-")
}
.width(min: 120, ideal: 160)
@@ -168,11 +174,11 @@ struct VideoListView: View {
.frame(minHeight: 300)
HStack {
Text("\(filteredVideos.count) 条,已选 \(selectedVideos.count)")
Text(verbatim: String(format: String(localized: "video.total_selected"), filteredVideos.count, selectedVideos.count))
.foregroundStyle(.secondary)
if !selectedVideos.isEmpty {
Button("取消选择") {
Button("video.deselect_all") {
selectedVideos.removeAll()
}
.buttonStyle(.borderless)
@@ -181,7 +187,7 @@ struct VideoListView: View {
Spacer()
Picker("每页", selection: $pageSizeIndex) {
Picker("video.page_size", selection: $pageSizeIndex) {
ForEach(0..<pageSizes.count, id: \.self) { i in
Text("\(pageSizes[i])").tag(i)
}
@@ -192,13 +198,13 @@ struct VideoListView: View {
updateDisplay()
}
Button("上一页") {
Button("video.prev_page") {
pageNo -= 1
updateDisplay()
}
.disabled(pageNo <= 1)
Text("\(pageNo)/\(totalPages)")
Button("下一页") {
Text(verbatim: String(format: String(localized: "video.page_info"), pageNo, totalPages))
Button("video.next_page") {
pageNo += 1
updateDisplay()
}
@@ -207,6 +213,7 @@ struct VideoListView: View {
.padding()
}
.task {
guard accountStore.activeAccount != nil else { return }
if allVideos.isEmpty {
await refreshFromAPI()
}
@@ -217,6 +224,7 @@ struct VideoListView: View {
displayVideos = []
selectedVideos = []
errorMessage = nil
guard newId != nil else { return }
Task { await refreshFromAPI() }
}
.sheet(isPresented: $showDownloadPanel) {
@@ -254,14 +262,16 @@ struct VideoListView: View {
errorMessage = nil
let maxRetries = 3
for attempt in 1...maxRetries {
loadingStatus = attempt == 1 ? "正在加载视频列表..." : "重试中... (第 \(attempt)/\(maxRetries) 次)"
loadingStatus = attempt == 1
? String(localized: "loading.video_list")
: String(format: String(localized: "loading.retry"), attempt, maxRetries)
var collected: [VideoItem] = []
var page = 1
let size = 100
var total = 0
do {
while true {
loadingStatus = "正在加载视频列表... (第 \(page) 页)"
loadingStatus = String(format: String(localized: "loading.video_list_page"), page)
let response = try await VODClient.shared.searchMedia(
pageNo: page,
pageSize: size
@@ -285,7 +295,7 @@ struct VideoListView: View {
let delay = UInt64(attempt) * 1_000_000_000
try? await Task.sleep(nanoseconds: delay)
} else {
errorMessage = "加载失败(已重试 \(maxRetries) 次):\(error.localizedDescription)"
errorMessage = String(format: String(localized: "loading.failed_retry"), maxRetries, error.localizedDescription)
}
}
}
@@ -300,7 +310,7 @@ struct VideoListView: View {
allVideos.removeAll { ids.contains($0.videoId) }
applyFilter()
} catch {
errorMessage = "删除失败:\(error.localizedDescription)"
errorMessage = String(format: String(localized: "loading.delete_failed"), error.localizedDescription)
}
}
@@ -312,11 +322,11 @@ struct VideoListView: View {
var errors: [String] = []
let selected = filteredVideos.filter { selectedVideos.contains($0.videoId) }
for (index, video) in selected.enumerated() {
loadingStatus = "正在获取下载地址 (\(index + 1)/\(selected.count))「\(video.title)」..."
loadingStatus = String(format: String(localized: "loading.fetch_play_url"), index + 1, selected.count, video.title)
let playInfos = await fetchPlayInfoWithRetry(videoId: video.videoId, title: video.title, maxRetries: 3)
results.append((video, playInfos))
if playInfos.isEmpty {
errors.append("\(video.title)」无可用播放地址")
errors.append(String(format: String(localized: "error.no_play_url"), video.title))
}
}
loadingStatus = ""
@@ -336,15 +346,11 @@ struct VideoListView: View {
return response.playInfos
}
if attempt < maxRetries {
print("[Download]「\(title)」第 \(attempt) 次获取到空播放地址,\(attempt)s 后重试...")
try? await Task.sleep(for: .seconds(attempt))
}
} catch {
if attempt < maxRetries {
print("[Download]「\(title)」第 \(attempt) 次获取失败: \(error)\(attempt)s 后重试...")
try? await Task.sleep(for: .seconds(attempt))
} else {
print("[Download]「\(title)\(maxRetries) 次尝试均失败: \(error)")
}
}
}