feat: 优化云盘工作区与批量文件工具

This commit is contained in:
2026-07-16 12:37:26 +08:00
parent 3cfc1617c5
commit fc22f63db4
9 changed files with 1615 additions and 385 deletions
+196 -44
View File
@@ -24,6 +24,7 @@ final class AppModel: ObservableObject {
@Published var similarFolders: [CloudFile] = []
@Published var detail: JSONValue?
@Published var detailFileID: String?
@Published var detailErrorMessage = ""
@Published var lastActionMessage = ""
@Published var tmdbAPIKey = UserDefaults.standard.string(forKey: "guangya.tmdbAPIKey") ?? ""
@Published var tmdbProxyHost = UserDefaults.standard.string(forKey: "guangya.tmdbProxyHost") ?? ""
@@ -31,6 +32,8 @@ final class AppModel: ObservableObject {
@Published var tmdbWorkflow = TMDBWorkflowConfig.load()
@Published var tmdbJobs: [TMDBJob] = []
@Published var tmdbTarget: CloudFile?
@Published var batchRenameTarget: CloudFile?
@Published var batchRenameSelection: [CloudFile] = []
@Published var isRunningTMDBJob = false
@Published var isAnalyzing = false
@Published var activeScanRequest: ScanRequest?
@@ -40,6 +43,10 @@ final class AppModel: ObservableObject {
@Published var isLoadingScanPicker = false
@Published var scanProgress = ScanProgress()
@Published var isScanning = false
@Published var isDeletingScanResults = false
@Published var scanDeletionProgress = ScanDeletionProgress()
@Published var isBatchRenaming = false
@Published var batchRenameProgress = BatchRenameProgress()
@Published var isBusy = false
@Published var statusMessage = ""
@Published var errorMessage = ""
@@ -59,9 +66,11 @@ final class AppModel: ObservableObject {
private var listResponseTask: Task<JSONValue, Error>?
private var activeListRequestID = UUID()
private var detailEnrichmentGeneration = UUID()
private var scanPickerRequestID = UUID()
private var detailCache: [String: (value: JSONValue, expiresAt: Date)] = [:]
private var detailRequestTasks: [String: Task<JSONValue, Error>] = [:]
private let detailRequestGate = DetailRequestGate(limit: 4)
private let metadataCache = FileMetadataCache.shared
private var isHandlingAuthorizationExpiry = false
private var scanTask: Task<Void, Never>?
@@ -72,6 +81,7 @@ final class AppModel: ObservableObject {
api = GuangyaAPI(accessToken: access, refreshToken: refresh)
if !access.isEmpty || refresh != nil {
isSignedIn = true
isLoadingFiles = true
Task { await loadAccount() }
}
}
@@ -138,12 +148,12 @@ final class AppModel: ObservableObject {
}
func loadVisibleItemDetails(_ file: CloudFile) {
guard file.size == nil || file.gcid == nil || file.modifiedAt.isEmpty else { return }
guard needsMetadata(for: file) else { return }
guard !detailLoadingIDs.contains(file.id) else { return }
detailLoadingIDs.insert(file.id)
Task { [weak self] in
guard let self else { return }
let detail = try? await self.cachedDetail(for: file.id)
let detail = try? await self.cachedDetail(for: file)
guard !Task.isCancelled, let index = self.files.firstIndex(where: { $0.id == file.id }) else { self.detailLoadingIDs.remove(file.id); return }
if let size = detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]) { self.files[index].size = size }
else if self.files[index].isDirectory { self.files[index].size = 0 }
@@ -318,6 +328,54 @@ final class AppModel: ObservableObject {
await perform("正在重命名…") { [self] in _ = try await api.fsRename(fileID: file.id, newName: trimmed); await loadFiles() }
}
func batchRenameCandidates(parentID: String?, rootName: String, recursive: Bool) async throws -> [CloudFile] {
var queue: [(id: String?, path: [String])] = [(parentID, parentID == nil ? [] : [rootName])]
var result: [CloudFile] = []
while !queue.isEmpty {
try Task.checkCancellation()
let location = queue.removeFirst()
var children = try await allFiles(parentID: location.id)
for index in children.indices {
children[index].cloudPath = "/" + (location.path + [children[index].name]).joined(separator: "/")
}
result += children
if recursive {
queue += children.filter(\.isDirectory).map { ($0.id, location.path + [$0.name]) }
}
}
return Array(Dictionary(grouping: result, by: \.id).compactMap { $0.value.first })
.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending }
}
func applyBatchRenames(_ changes: [BatchRenameChange]) async -> BatchRenameExecutionResult {
guard !changes.isEmpty, !isBatchRenaming else { return BatchRenameExecutionResult(succeeded: 0, failed: 0) }
isBatchRenaming = true
batchRenameProgress = BatchRenameProgress(completed: 0, total: changes.count)
defer { isBatchRenaming = false }
var succeeded = 0
var failures: [String] = []
for change in changes {
batchRenameProgress.currentName = change.file.name
do {
_ = try await api.fsRename(fileID: change.file.id, newName: change.newName)
succeeded += 1
} catch {
failures.append("\(change.file.name)\(error.localizedDescription)")
if isAuthorizationExpiry(error) {
handleAuthorizationExpiry()
break
}
}
batchRenameProgress.completed += 1
}
if isSignedIn { await loadFiles(force: true) }
let failed = changes.count - succeeded
lastActionMessage = failures.isEmpty
? "已完成 \(succeeded) 项重命名"
: "已完成 \(succeeded) 项,失败 \(failed)\n" + failures.prefix(5).joined(separator: "\n")
return BatchRenameExecutionResult(succeeded: succeeded, failed: failed)
}
func copy(_ file: CloudFile, to parentID: String?) async {
await perform("正在复制…") { [self] in _ = try await api.fsCopy(fileIDs: [file.id], parentID: parentID); await loadFiles() }
}
@@ -340,19 +398,25 @@ final class AppModel: ObservableObject {
// its resource ID so a slower previous selection can never overwrite it.
detailFileID = file.id
detail = nil
detailErrorMessage = ""
detailLoadingIDs.insert(file.id)
defer { detailLoadingIDs.remove(file.id) }
do {
let value = try await cachedDetail(for: file.id)
let value = try await cachedDetail(for: file)
guard !Task.isCancelled, detailFileID == file.id else { return }
detail = value
} catch is CancellationError { }
catch {
guard detailFileID == file.id else { return }
detail = nil; lastActionMessage = "详情请求失败:\(error.localizedDescription)"
detail = nil
detailErrorMessage = error.localizedDescription
}
}
func details(for file: CloudFile, forceRefresh: Bool = false) async throws -> JSONValue {
try await cachedDetail(for: file, forceRefresh: forceRefresh)
}
func share(_ file: CloudFile) async {
await perform("正在创建分享…") { [self] in
let result = try await api.shareCreate(fileIDs: [file.id], title: file.name)
@@ -382,49 +446,92 @@ final class AppModel: ObservableObject {
scanTask = Task { [weak self] in
guard let self else { return }
defer { self.isScanning = false }
do { self.scanResult = try await self.runScan(request) }
catch is CancellationError { self.scanProgress.phase = "扫描已取消" }
catch { self.errorMessage = error.localizedDescription }
do {
self.scanResult = try await self.runScan(request)
self.scanProgress.phase = "扫描完成"
}
catch is CancellationError {
self.scanResult = ScanResult(
request: request,
items: self.liveScanItems,
foldersScanned: self.scanProgress.foldersVisited,
filesScanned: self.scanProgress.filesVisited,
skippedFolders: 0
)
self.scanProgress.phase = "扫描已取消,已保留当前结果"
}
catch {
self.scanProgress.phase = "扫描失败"
self.errorMessage = error.localizedDescription
}
}
}
func cancelScan() { scanTask?.cancel() }
func loadScanPickerFolders(parentID: String? = nil) async {
guard !isLoadingScanPicker else { return }
let requestID = UUID()
scanPickerRequestID = requestID
isLoadingScanPicker = true
defer { isLoadingScanPicker = false }
do { scanPickerFolders = try await allFiles(parentID: parentID, pageSize: 5_000).filter(\.isDirectory) }
catch { errorMessage = error.localizedDescription }
scanPickerFolders = []
defer { if scanPickerRequestID == requestID { isLoadingScanPicker = false } }
do {
let folders = try await allFiles(parentID: parentID, pageSize: 5_000).filter(\.isDirectory)
guard scanPickerRequestID == requestID else { return }
scanPickerFolders = folders
} catch {
guard scanPickerRequestID == requestID else { return }
errorMessage = error.localizedDescription
}
}
func deleteScannedEmptyFolders(_ ids: Set<String>) async {
guard let result = scanResult, result.request.kind == .emptyFolders, !ids.isEmpty else { return }
guard let result = scanResult, result.request.kind == .emptyFolders, !ids.isEmpty, !isDeletingScanResults else { return }
let targets = result.emptyFolders.reversed().filter { ids.contains($0.id) }
isDeletingScanResults = true
scanDeletionProgress = ScanDeletionProgress(phase: "正在复核空文件夹…", completed: 0, total: targets.count)
defer { isDeletingScanResults = false }
await perform("正在复核并删除空文件夹…") { [self] in
var deleted = 0; var skipped = 0
for folder in result.emptyFolders.reversed() where ids.contains(folder.id) {
var deletedIDs = Set<String>(); var skipped = 0
for folder in targets {
scanDeletionProgress.phase = "正在复核 \(folder.name)"
let children = try await allFiles(parentID: folder.id)
guard children.isEmpty else { skipped += 1; continue }
actionLoadingIDs.insert(folder.id)
defer { actionLoadingIDs.remove(folder.id) }
_ = try await api.fsDelete(fileIDs: [folder.id])
deleted += 1
if children.isEmpty {
scanDeletionProgress.phase = "正在删除 \(folder.name)"
actionLoadingIDs.insert(folder.id)
do {
defer { actionLoadingIDs.remove(folder.id) }
_ = try await api.fsDelete(fileIDs: [folder.id])
}
deletedIDs.insert(folder.id)
} else {
skipped += 1
}
scanDeletionProgress.completed += 1
}
scanResult = nil
files.removeAll { ids.contains($0.id) }
lastActionMessage = skipped == 0 ? "已删除 \(deleted) 个空文件夹" : "已删除 \(deleted) 个,跳过 \(skipped) 个已变更文件夹"
let remaining = result.emptyFolders.filter { !deletedIDs.contains($0.id) }
scanResult = ScanResult(request: result.request, items: remaining.map(ScanItem.emptyFolder), foldersScanned: result.foldersScanned, filesScanned: result.filesScanned, skippedFolders: result.skippedFolders + skipped)
files.removeAll { deletedIDs.contains($0.id) }
scanDeletionProgress.phase = "删除完成"
lastActionMessage = skipped == 0 ? "已删除 \(deletedIDs.count) 个空文件夹" : "已删除 \(deletedIDs.count) 个,跳过 \(skipped) 个已变更文件夹"
}
}
func deleteScannedFiles(_ ids: Set<String>) async {
guard let result = scanResult, result.request.kind != .emptyFolders, !ids.isEmpty else { return }
guard let result = scanResult, result.request.kind != .emptyFolders, !ids.isEmpty, !isDeletingScanResults else { return }
isDeletingScanResults = true
scanDeletionProgress = ScanDeletionProgress(phase: "正在删除已选项目…", completed: 0, total: ids.count)
defer { isDeletingScanResults = false }
await perform("正在删除已选文件…") { [self] in
var deleted = 0
for id in ids {
actionLoadingIDs.insert(id)
defer { actionLoadingIDs.remove(id) }
_ = try await api.fsDelete(fileIDs: [id])
do {
defer { actionLoadingIDs.remove(id) }
_ = try await api.fsDelete(fileIDs: [id])
}
deleted += 1
scanDeletionProgress.completed = deleted
}
files.removeAll { ids.contains($0.id) }
switch result.request.kind {
@@ -444,6 +551,7 @@ final class AppModel: ObservableObject {
scanResult = ScanResult(request: result.request, items: groups.map(ScanItem.similar), foldersScanned: result.foldersScanned, filesScanned: result.filesScanned, skippedFolders: result.skippedFolders)
case .emptyFolders: break
}
scanDeletionProgress.phase = "删除完成"
lastActionMessage = "已删除 \(deleted)"
}
}
@@ -451,7 +559,8 @@ final class AppModel: ObservableObject {
private func runScan(_ request: ScanRequest) async throws -> ScanResult {
let root: String? = request.scope == .wholeDrive ? nil : request.rootID
let recursive = request.scope != .currentFolder
let snapshot = try await scanTree(parentID: root, recursive: recursive)
let rootPath = root == nil ? [] : request.rootName.split(separator: "/").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
let snapshot = try await scanTree(parentID: root, recursive: recursive, pathComponents: rootPath)
switch request.kind {
case .emptyFolders:
let candidates = snapshot.folders.filter { $0.isEmptyAfterCandidates }
@@ -463,9 +572,13 @@ final class AppModel: ObservableObject {
if let minimum = request.excludeFilesSmallerThan { candidates = candidates.filter { ($0.size ?? 0) >= minimum } }
let detailed = await withTaskGroup(of: CloudFile.self, returning: [CloudFile].self) { group in
for file in candidates {
group.addTask { [api] in
group.addTask { [api, metadataCache] in
var copy = file
if copy.gcid == nil, let detail = try? await api.fsDetail(fileID: file.id) { copy.gcid = detail.firstStringDeep(["gcid", "gcId", "hash"]) }
if copy.gcid == nil, let detail = try? await api.fsDetail(fileID: file.id) {
copy.gcid = detail.firstStringDeep(["gcid", "gcId", "hash"])
if let size = detail.firstInt64Deep(["size", "fileSize", "resSize"]) { copy.size = size }
await metadataCache.save(fileID: file.id, isDirectory: false, detail: detail)
}
return copy
}
}
@@ -474,7 +587,9 @@ final class AppModel: ObservableObject {
var buckets: [String: [CloudFile]] = [:]
for file in detailed { if let gcid = file.gcid, !gcid.isEmpty { buckets[gcid, default: []].append(file) } }
var groups: [DuplicateGroup] = []
for (gcid, files) in buckets where files.count > 1 { groups.append(DuplicateGroup(id: gcid, files: files)) }
for (gcid, files) in buckets where files.count > 1 {
groups.append(DuplicateGroup(id: gcid, files: files.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending }))
}
groups.sort { $0.files.count > $1.files.count }
return ScanResult(request: request, items: groups.map(ScanItem.duplicate), foldersScanned: scanProgress.foldersVisited, filesScanned: scanProgress.filesVisited, skippedFolders: 0)
case .similarFolders:
@@ -484,12 +599,18 @@ final class AppModel: ObservableObject {
}
private struct ScanTree { let allFiles: [CloudFile]; let folders: [(file: CloudFile, isEmptyAfterCandidates: Bool)] }
private func scanTree(parentID: String?, recursive: Bool) async throws -> ScanTree {
private func scanTree(parentID: String?, recursive: Bool, pathComponents: [String]) async throws -> ScanTree {
try Task.checkCancellation()
let children = try await allFiles(parentID: parentID)
var children = try await allFiles(parentID: parentID)
for index in children.indices {
children[index].cloudPath = "/" + (pathComponents + [children[index].name]).joined(separator: "/")
}
// A directory whose content request is empty is immediately publishable.
// This keeps the scan result live while deeper recursion continues.
if let parentID, children.isEmpty, let parent = files.first(where: { $0.id == parentID }) ?? liveScanItems.compactMap({ if case .emptyFolder(let file) = $0 { return file }; return nil }).first(where: { $0.id == parentID }) {
if activeScanRequest?.kind == .emptyFolders,
let parentID,
children.isEmpty,
let parent = files.first(where: { $0.id == parentID }) ?? liveScanItems.compactMap({ if case .emptyFolder(let file) = $0 { return file }; return nil }).first(where: { $0.id == parentID }) {
let item = ScanItem.emptyFolder(parent)
if !liveScanItems.contains(where: { $0.id == item.id }) { liveScanItems.append(item) }
}
@@ -498,7 +619,7 @@ final class AppModel: ObservableObject {
var all = children; var candidates: [(CloudFile, Bool)] = []
for folder in children where folder.isDirectory {
if recursive {
let subtree = try await scanTree(parentID: folder.id, recursive: true)
let subtree = try await scanTree(parentID: folder.id, recursive: true, pathComponents: pathComponents + [folder.name])
all += subtree.allFiles
candidates += subtree.folders
let hasNonEmptyChild = subtree.folders.contains { $0.file.id == folder.id && !$0.isEmptyAfterCandidates }
@@ -537,7 +658,8 @@ final class AppModel: ObservableObject {
// Let SwiftUI process pending input/drawing between very large pages.
await Task.yield()
}
return Array(Dictionary(grouping: collected, by: \.id).compactMap { $0.value.first })
let unique = Array(Dictionary(grouping: collected, by: \.id).compactMap { $0.value.first })
return await hydrateFromMetadataCache(unique)
}
func scanDuplicates(excludingSmallFiles: Bool = false) async {
@@ -548,11 +670,13 @@ final class AppModel: ObservableObject {
let api = self.api
let detailed = await withTaskGroup(of: CloudFile?.self, returning: [CloudFile].self) { group in
for file in candidates {
group.addTask {
group.addTask { [metadataCache] in
guard file.gcid == nil else { return file }
guard let detail = try? await api.fsDetail(fileID: file.id) else { return file }
var copy = file
copy.gcid = detail.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"])
copy.size = detail.firstInt64Deep(["size", "fileSize", "resSize"])
await metadataCache.save(fileID: file.id, isDirectory: false, detail: detail)
return copy
}
}
@@ -603,7 +727,8 @@ final class AppModel: ObservableObject {
defer { isRunningTMDBJob = false }
do {
let allowed = Set(extensionsText.lowercased().split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) })
let snapshot = try await scanTree(parentID: sourceID, recursive: recursive)
let sourcePath = sourceID == nil ? [] : [tmdbTarget?.name ?? "所选文件夹"]
let snapshot = try await scanTree(parentID: sourceID, recursive: recursive, pathComponents: sourcePath)
let minimum = Int64(max(0, minimumSizeMB)) * 1024 * 1024
let candidates = snapshot.allFiles.filter { file in
guard !file.isDirectory, (file.size ?? 0) >= minimum else { return false }
@@ -663,7 +788,6 @@ final class AppModel: ObservableObject {
for index in tmdbJobs.indices where tmdbJobs[index].state == .matched && tmdbJobs[index].isApproved {
guard let match = tmdbJobs[index].match else { continue }
do {
let year = String(match.releaseDate.prefix(4))
let baseFolder = renderTMDBTemplate(tmdbWorkflow.folderTemplate, match: match, fallback: match.title)
// Every season is an independent library root: its own TMDB ID,
// NFO, artwork, cast and episodes all live in the same folder.
@@ -857,6 +981,7 @@ final class AppModel: ObservableObject {
folderPath = []
detail = nil
detailFileID = nil
detailErrorMessage = ""
scanResult = nil
liveScanItems = []
statusMessage = ""
@@ -865,10 +990,13 @@ final class AppModel: ObservableObject {
private func completeLogin() {
persistTokens()
isSignedIn = true
isLoadingFiles = true
Task { await loadAccount() }
}
private func loadAccount() async {
isLoadingFiles = true
defer { isLoadingFiles = false }
do {
try await api.prepareSession()
} catch {
@@ -876,8 +1004,6 @@ final class AppModel: ObservableObject {
return
}
persistTokens()
isLoadingFiles = true
defer { isLoadingFiles = false }
async let userResult: JSONValue? = try? await api.userInfo()
async let fileResult: JSONValue? = try? await filesResponse()
if let result = await userResult {
@@ -886,7 +1012,7 @@ final class AppModel: ObservableObject {
statusMessage = "登录状态已恢复"
}
if let result = await fileResult {
files = extractFiles(from: result)
files = await hydrateFromMetadataCache(extractFiles(from: result))
updatePaging(from: result)
// Render the directory response immediately. Per-file detail calls are
// expensive and must never block or saturate the homepage on launch.
@@ -910,7 +1036,7 @@ final class AppModel: ObservableObject {
do {
let result = try await task.value
guard !Task.isCancelled, activeListRequestID == requestID else { return }
files = extractFiles(from: result)
files = await hydrateFromMetadataCache(extractFiles(from: result))
updatePaging(from: result)
// Render the directory response immediately. Per-file detail calls are
// expensive and must never block or saturate the homepage on launch.
@@ -1023,7 +1149,9 @@ final class AppModel: ObservableObject {
detailEnrichmentTask?.cancel()
detailEnrichmentGeneration = UUID()
let generation = detailEnrichmentGeneration
let page = force ? files : files.filter { $0.size == nil || $0.gcid == nil || $0.modifiedAt.isEmpty }
let page = files.filter { file in
file.isDirectory ? (force || needsMetadata(for: file)) : needsMetadata(for: file)
}
guard !page.isEmpty else {
isLoadingFolderSizes = false
detailLoadingIDs = []
@@ -1042,6 +1170,7 @@ final class AppModel: ObservableObject {
private func enrichPageDetails(_ page: [CloudFile], generation: UUID) async {
let api = self.api
let metadataCache = self.metadataCache
let concurrencyLimit = 6
var iterator = page.makeIterator()
await withTaskGroup(of: (String, Int64?, String?, Int64?, Int?, Int?).self) { group in
@@ -1049,6 +1178,7 @@ final class AppModel: ObservableObject {
guard let file = iterator.next() else { return }
group.addTask {
let detail = try? await api.fsDetail(fileID: file.id)
if let detail { await metadataCache.save(fileID: file.id, isDirectory: file.isDirectory, detail: detail) }
return (
file.id,
detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]),
@@ -1083,16 +1213,25 @@ final class AppModel: ObservableObject {
isLoadingFolderSizes = false
}
private func cachedDetail(for fileID: String, forceRefresh: Bool = false) async throws -> JSONValue {
private func cachedDetail(for file: CloudFile, forceRefresh: Bool = false) async throws -> JSONValue {
let fileID = file.id
if !forceRefresh, let cached = detailCache[fileID], cached.expiresAt > Date() { return cached.value }
if !forceRefresh, let cached = await metadataCache.metadata(for: fileID), cached.isUsable {
let value = cached.detailValue
detailCache[fileID] = (value, Date().addingTimeInterval(300))
return value
}
if let task = detailRequestTasks[fileID] { return try await task.value }
let api = self.api
let gate = detailRequestGate
let metadataCache = self.metadataCache
let task = Task<JSONValue, Error> {
await gate.acquire()
defer { Task { await gate.release() } }
try Task.checkCancellation()
return try await api.fsDetail(fileID: fileID)
let value = try await api.fsDetail(fileID: fileID)
await metadataCache.save(fileID: fileID, isDirectory: file.isDirectory, detail: value)
return value
}
detailRequestTasks[fileID] = task
defer { detailRequestTasks[fileID] = nil }
@@ -1102,6 +1241,19 @@ final class AppModel: ObservableObject {
return value
}
private func hydrateFromMetadataCache(_ source: [CloudFile]) async -> [CloudFile] {
await metadataCache.save(files: source)
let cached = await metadataCache.metadata(for: source.map(\.id))
return source.map { file in cached[file.id]?.applying(to: file) ?? file }
}
private func needsMetadata(for file: CloudFile) -> Bool {
if file.isDirectory {
return file.subDirectoryCount == nil || file.subFileCount == nil
}
return file.gcid?.isEmpty != false
}
private static func formatDate(_ epoch: Int64) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")