import AppKit import Combine import Foundation @MainActor final class AppModel: ObservableObject { @Published var isSignedIn = false @Published var user = UserProfile(json: .object([:])) @Published var section: WorkspaceSection = .files @Published var files: [CloudFile] = [] @Published var folderPath: [FolderPath] = [] @Published var isLoadingFiles = false @Published var isLoadingFolderSizes = false @Published var detailLoadingIDs: Set = [] @Published var actionLoadingIDs: Set = [] @Published var currentPage = 0 @Published var pageSize = 50 @Published var totalPages = 1 @Published var serverSort: FileSort = .name @Published var serverSortDirection: SortDirection = .ascending @Published var sizeLoadingCompleted = 0 @Published var sizeLoadingTotal = 0 @Published var duplicateGroups: [DuplicateGroup] = [] @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") ?? "" @Published var tmdbProxyPort = UserDefaults.standard.string(forKey: "guangya.tmdbProxyPort") ?? "" @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? @Published var scanResult: ScanResult? @Published var liveScanItems: [ScanItem] = [] @Published var scanPickerFolders: [CloudFile] = [] @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 = "" @Published var phoneNumber = "+86 " @Published var verificationCode = "" @Published var captchaToken = "" @Published var verificationID = "" @Published var qrPayload = "" @Published var qrToken = "" @Published var qrStatus = "等待生成二维码" @Published var codeCountdown = 0 let api: GuangyaAPI private var qrPollingTask: Task? private var countdownTask: Task? private var detailEnrichmentTask: Task? private var listResponseTask: Task? 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] = [:] private let detailRequestGate = DetailRequestGate(limit: 4) private let metadataCache = FileMetadataCache.shared private var isHandlingAuthorizationExpiry = false private var scanTask: Task? init() { let defaults = UserDefaults.standard let access = defaults.string(forKey: "guangya.accessToken") ?? "" let refresh = defaults.string(forKey: "guangya.refreshToken") api = GuangyaAPI(accessToken: access, refreshToken: refresh) if !access.isEmpty || refresh != nil { isSignedIn = true isLoadingFiles = true Task { await loadAccount() } } } func sendVerificationCode() async { guard phoneNumber.filter({ $0.isNumber }).count >= 8 else { errorMessage = "请输入有效的手机号"; return } await perform("正在发送验证码…") { [self] in let initResult = try await api.loginSMSInit(phoneNumber: phoneNumber) guard let captcha = initResult.firstString(["captcha_token", "captchaToken"]) else { if initResult.firstString(["url", "verify_url", "verifyUrl"]) != nil { throw GuangyaAPIError.http(status: 403, message: "需要完成验证码验证后再发送短信") } throw GuangyaAPIError.missingField("captcha_token") } captchaToken = captcha let result = try await api.loginSMSSend(phoneNumber: phoneNumber, captchaToken: captcha) guard let id = result.firstString(["verification_id", "verificationId", "id"]) else { throw GuangyaAPIError.missingField("verification_id") } verificationID = id startCountdown() statusMessage = "验证码已发送" } } func verifyAndSignIn() async { guard !verificationID.isEmpty, !captchaToken.isEmpty else { errorMessage = "请先发送验证码"; return } guard verificationCode.count >= 4 else { errorMessage = "请输入短信验证码"; return } await perform("正在登录…") { [self] in let verified = try await api.loginSMSVerify(verificationID: verificationID, verificationCode: verificationCode) guard let token = verified.firstString(["verification_token", "verificationToken"]) else { throw GuangyaAPIError.missingField("verification_token") } _ = try await api.loginSMSSignIn(code: verificationCode, verificationToken: token, username: phoneNumber, captchaToken: captchaToken) completeLogin() } } func startQRLogin() async { qrPollingTask?.cancel() qrPayload = "" qrToken = "" qrStatus = "正在生成二维码…" await perform("正在生成二维码…") { [self] in let result = try await api.loginQRInit() qrToken = result.firstStringDeep(["device_code", "deviceCode", "qr_token", "qrToken", "token", "id"]) ?? "" qrPayload = result.firstStringDeep(["verification_uri_complete", "verificationUriComplete", "qr_url", "qrUrl", "url", "verification_uri", "verificationUri", "qrcode", "qrCode", "code"]) ?? qrToken guard !qrPayload.isEmpty else { throw GuangyaAPIError.missingField("二维码内容") } qrStatus = "请使用光鸭 App 扫码" let expiresIn = result.firstIntDeep(["expires_in", "expiresIn"]) ?? 120 let interval = max(2, result.firstIntDeep(["interval", "pollInterval", "poll_interval"]) ?? 3) startQRPolling(expiresIn: expiresIn, interval: interval) } } func stopQRLogin() { qrPollingTask?.cancel(); qrPollingTask = nil } func refresh() async { guard isSignedIn else { return } // Refresh is an explicit recovery action: abandon a stalled first-page // request and start a fresh one instead of leaving the button disabled. listResponseTask?.cancel() detailEnrichmentTask?.cancel() activeListRequestID = UUID() await loadFiles(force: true) } func reloadCurrentListSizes() { schedulePageDetailEnrichment(force: true) } func loadVisibleItemDetails(_ file: CloudFile) { 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) 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 } if let gcid = detail?.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]) { self.files[index].gcid = gcid } if let epoch = detail?.firstInt64Deep(["utime", "ctime"]) { self.files[index].modifiedAt = Self.formatDate(epoch) } if let count = detail?.firstIntDeep(["subDirCount"]) { self.files[index].subDirectoryCount = count } if let count = detail?.firstIntDeep(["subFileCount"]) { self.files[index].subFileCount = count } self.detailLoadingIDs.remove(file.id) } } func openFolder(_ file: CloudFile) async { guard file.isDirectory, !isLoadingFiles else { return } currentPage = 0 folderPath.append(FolderPath(id: file.id, name: file.name)) await loadFiles() } func navigateToRoot() async { guard !folderPath.isEmpty, !isLoadingFiles else { return } folderPath.removeAll() currentPage = 0 await loadFiles() } func navigateToFolder(at index: Int) async { guard folderPath.indices.contains(index), !isLoadingFiles else { return } folderPath.removeSubrange((index + 1)..) async { let targets = files.filter { ids.contains($0.id) } guard !targets.isEmpty else { return } await perform("正在删除 \(targets.count) 项…") { [self] in actionLoadingIDs.formUnion(ids) defer { actionLoadingIDs.subtract(ids) } // One request preserves the server's batch semantics and avoids partial UI updates. _ = try await api.fsDelete(fileIDs: targets.map(\.id)) files.removeAll { ids.contains($0.id) } duplicateGroups = duplicateGroups.compactMap { group in let kept = group.files.filter { !ids.contains($0.id) } return kept.count > 1 ? DuplicateGroup(id: group.id, files: kept) : nil } similarFolders.removeAll { ids.contains($0.id) } } } func delete(_ file: CloudFile) async { actionLoadingIDs.insert(file.id) defer { actionLoadingIDs.remove(file.id) } await perform("正在删除…") { [self] in _ = try await api.fsDelete(fileIDs: [file.id]) // The list, its enriched size data, and folder counts are already local. // Apply the successful deletion in place instead of reloading every row. files.removeAll { $0.id == file.id } duplicateGroups = duplicateGroups.compactMap { group in let kept = group.files.filter { $0.id != file.id } return kept.count > 1 ? DuplicateGroup(id: group.id, files: kept) : nil } similarFolders.removeAll { $0.id == file.id } } } func goToPage(_ page: Int) async { let target = max(0, min(page, totalPages - 1)) guard target != currentPage, !isLoadingFiles else { return } currentPage = target await loadFiles() } func setPageSize(_ size: Int) async { guard size != pageSize else { return } pageSize = size currentPage = 0 await loadFiles() } func setServerSort(_ sort: FileSort) async { if serverSort == sort { serverSortDirection = serverSortDirection == .ascending ? .descending : .ascending } else { serverSort = sort; serverSortDirection = .ascending } currentPage = 0 await loadFiles() } func setServerSort(_ sort: FileSort, direction: SortDirection) async { guard serverSort != sort || serverSortDirection != direction else { return } serverSort = sort serverSortDirection = direction currentPage = 0 await loadFiles() } var scanScopeLocationName: String { if section == .files { return folderPath.isEmpty ? "云盘根目录" : folderPath.map(\.name).joined(separator: " / ") } return section.rawValue } var currentScanRootID: String? { section == .files ? folderPath.last?.id : nil } func upload(url: URL) async { await perform("正在上传 \(url.lastPathComponent)…") { [self] in _ = try await api.fileUpload(url: url, parentID: folderPath.last?.id); await loadFiles() } } func open(_ file: CloudFile) async { if file.isDirectory { await openFolder(file) } else { await preview(file) } } func remoteURL(for file: CloudFile) async throws -> URL { let detail = try await api.fsDetail(fileID: file.id) // Images expose their signed preview directly in picInfo.previewUrl. if file.fileType == 1, let value = detail.firstStringDeep(["previewUrl", "previewURL", "originalUrl", "originalURL", "thumbnail"]), let url = URL(string: value) { return url } // The web player does not use the generic download endpoint for videos. // It selects a videoResource GCID then asks get_vod_download_url for the // signed media address, which AVPlayer can consume directly. if file.fileType == 2, let resources = detail.firstArrayDeep(["videoResource"]), let defaultResource = resources.first(where: { $0["info"]?["defaultResolution"]?.boolValue == true }) ?? resources.first, let gcid = defaultResource.firstStringDeep(["gcid"]), let result = try? await api.vodDownloadURL(fileID: file.id, gcid: gcid), let value = result.firstStringDeep(["signedURL", "signedUrl", "url", "downloadUrl", "download_url", "dlink"]), let url = URL(string: value) { return url } // Documents and unsupported media fall back to their signed direct link. let result = try await api.downloadURL(fileID: file.id) guard let value = result.firstStringDeep(["url", "downloadUrl", "download_url", "dlink"]), let url = URL(string: value) else { throw GuangyaAPIError.missingField("可预览地址或直链") } return url } func preview(_ file: CloudFile) async { await perform("正在准备预览…") { [self] in NSWorkspace.shared.open(try await remoteURL(for: file)) } } func download(_ file: CloudFile) async { await perform("正在准备下载…") { [self] in NSWorkspace.shared.open(try await remoteURL(for: file)) } } func playWithExternalPlayer(_ file: CloudFile) async { await perform("正在交给外部播放器…") { [self] in let url = try await remoteURL(for: file) let candidates = ["IINA", "VLC"] for name in candidates { if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: name == "IINA" ? "com.colliderli.iina" : "org.videolan.vlc") { try await NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: .init()) lastActionMessage = "已使用 \(name) 打开“\(file.name)”" return } } NSWorkspace.shared.open(url) lastActionMessage = "未发现 IINA 或 VLC,已交给系统默认播放器" } } func rename(_ file: CloudFile, to name: String) async { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed != file.name else { return } 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() } } func move(fileIDs: [String], to parentID: String?) async { guard !fileIDs.isEmpty else { return } await perform("正在移动…") { [self] in _ = try await api.fsMove(fileIDs: fileIDs, parentID: parentID); await loadFiles() } } func move(_ file: CloudFile, to parentID: String?) async { await perform("正在移动…") { [self] in _ = try await api.fsMove(fileIDs: [file.id], parentID: parentID); await loadFiles() } } func restore(_ file: CloudFile) async { await perform("正在恢复…") { [self] in _ = try await api.fsRecycle(fileIDs: [file.id]); await loadFiles() } } func showDetails(_ file: CloudFile) async { // Inspector loading is independent from global isBusy; bind the answer to // 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) guard !Task.isCancelled, detailFileID == file.id else { return } detail = value } catch is CancellationError { } catch { guard detailFileID == file.id else { return } 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) if let url = result.firstStringDeep(["url", "shareUrl", "share_url", "link"]) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(url, forType: .string) lastActionMessage = "分享链接已复制" } else { lastActionMessage = "分享已创建" } } } func cleanEmptyFolders() async { // Kept for legacy menu callers: this is now scan-only and never deletes. startScan(ScanRequest(kind: .emptyFolders, scope: .wholeDrive, rootID: nil, rootName: "云盘根目录", excludeFilesSmallerThan: nil)) } func startScan(_ request: ScanRequest) { guard !isScanning else { return } scanTask?.cancel() activeScanRequest = request scanResult = nil liveScanItems = [] scanProgress = ScanProgress(phase: "正在准备扫描…") isScanning = true scanTask = Task { [weak self] in guard let self else { return } defer { self.isScanning = false } 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 { let requestID = UUID() scanPickerRequestID = requestID isLoadingScanPicker = true 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) async { 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 deletedIDs = Set(); var skipped = 0 for folder in targets { scanDeletionProgress.phase = "正在复核 \(folder.name)" let children = try await allFiles(parentID: folder.id) 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 } 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) async { 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) 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 { case .duplicates: let groups = result.items.compactMap { item -> DuplicateGroup? in guard case .duplicate(let group) = item else { return nil } let kept = group.files.filter { !ids.contains($0.id) } return kept.count > 1 ? DuplicateGroup(id: group.id, files: kept) : nil } scanResult = ScanResult(request: result.request, items: groups.map(ScanItem.duplicate), foldersScanned: result.foldersScanned, filesScanned: result.filesScanned, skippedFolders: result.skippedFolders) case .similarFolders: let groups = result.items.compactMap { item -> SimilarFolderGroup? in guard case .similar(let group) = item else { return nil } let kept = group.folders.filter { !ids.contains($0.id) } return kept.count > 1 ? SimilarFolderGroup(id: group.id, folders: kept) : nil } 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) 项" } } private func runScan(_ request: ScanRequest) async throws -> ScanResult { let root: String? = request.scope == .wholeDrive ? nil : request.rootID let recursive = request.scope != .currentFolder 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 } let finalItems = candidates.map { ScanItem.emptyFolder($0.file) } liveScanItems = finalItems return ScanResult(request: request, items: finalItems, foldersScanned: scanProgress.foldersVisited, filesScanned: scanProgress.filesVisited, skippedFolders: 0) case .duplicates: var candidates = snapshot.allFiles.filter { !$0.isDirectory } 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, 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 let size = detail.firstInt64Deep(["size", "fileSize", "resSize"]) { copy.size = size } await metadataCache.save(fileID: file.id, isDirectory: false, detail: detail) } return copy } } var all: [CloudFile] = []; for await file in group { all.append(file) }; return all } 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.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: let groups = Dictionary(grouping: snapshot.allFiles.filter(\.isDirectory), by: { normalizedFolderName($0.name) }).compactMap { key, folders -> SimilarFolderGroup? in folders.count > 1 ? SimilarFolderGroup(id: key, folders: folders) : nil } return ScanResult(request: request, items: groups.map(ScanItem.similar), foldersScanned: scanProgress.foldersVisited, filesScanned: scanProgress.filesVisited, skippedFolders: 0) } } private struct ScanTree { let allFiles: [CloudFile]; let folders: [(file: CloudFile, isEmptyAfterCandidates: Bool)] } private func scanTree(parentID: String?, recursive: Bool, pathComponents: [String]) async throws -> ScanTree { try Task.checkCancellation() 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 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) } } scanProgress.filesVisited += children.filter { !$0.isDirectory }.count scanProgress.foldersVisited += children.filter(\.isDirectory).count 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, pathComponents: pathComponents + [folder.name]) all += subtree.allFiles candidates += subtree.folders let hasNonEmptyChild = subtree.folders.contains { $0.file.id == folder.id && !$0.isEmptyAfterCandidates } let hasDirectFiles = subtree.allFiles.contains { !$0.isDirectory && $0.id != folder.id } let isEmptyCandidate = !hasNonEmptyChild && !hasDirectFiles candidates.append((folder, isEmptyCandidate)) if isEmptyCandidate { let item = ScanItem.emptyFolder(folder) if !liveScanItems.contains(where: { $0.id == item.id }) { liveScanItems.append(item) } } } else { candidates.append((folder, true)) if children.isEmpty { let item = ScanItem.emptyFolder(folder) if !liveScanItems.contains(where: { $0.id == item.id }) { liveScanItems.append(item) } } } } return ScanTree(allFiles: all, folders: candidates) } private func allFiles(parentID: String?, pageSize: Int = 5_000) async throws -> [CloudFile] { // Scans intentionally use a large page. It avoids 25–100 round trips for // ordinary media folders while still paging safely when a folder is huge. let requestedSize = max(1, min(pageSize, 10_000)) var page = 0; var collected: [CloudFile] = [] while true { try Task.checkCancellation() scanProgress.phase = "正在读取目录内容…" let response = try await api.fsFiles(parentID: parentID, page: page, pageSize: requestedSize) let items = extractFiles(from: response) collected += items let total = response.firstIntDeep(["total", "totalCount", "count"]) if items.count < requestedSize || (total != nil && collected.count >= total!) { break } page += 1 // Let SwiftUI process pending input/drawing between very large pages. await Task.yield() } let unique = Array(Dictionary(grouping: collected, by: \.id).compactMap { $0.value.first }) return await hydrateFromMetadataCache(unique) } func scanDuplicates(excludingSmallFiles: Bool = false) async { guard !isAnalyzing else { return } isAnalyzing = true defer { isAnalyzing = false } let candidates = files.filter { !$0.isDirectory } let api = self.api let detailed = await withTaskGroup(of: CloudFile?.self, returning: [CloudFile].self) { group in for file in candidates { 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 } } var result: [CloudFile] = [] for await file in group { if let file { result.append(file) } } return result } let eligible = excludingSmallFiles ? detailed.filter { ($0.size ?? 0) >= 1024 * 1024 } : detailed let groups: [String: [(String, CloudFile)]] = Dictionary(grouping: eligible.compactMap { file in file.gcid.map { ($0, file) } }, by: { $0.0 }) duplicateGroups = groups.compactMap { key, values in guard values.count > 1 else { return nil } return DuplicateGroup(id: key, files: values.map { $0.1 }) }.sorted { $0.files.count > $1.files.count } lastActionMessage = duplicateGroups.isEmpty ? "没有发现重复文件" : "发现 \(duplicateGroups.count) 组重复文件" } func copyTransferJSON(for file: CloudFile) { let payload: [String: Any] = ["name": file.name, "fileId": file.id, "size": file.size ?? 0, "gcid": file.gcid ?? ""] guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]), let text = String(data: data, encoding: .utf8) else { return } NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) let panel = NSSavePanel() panel.nameFieldStringValue = "\(file.name).transfer.json" panel.allowedContentTypes = [.json] if panel.runModal() == .OK, let url = panel.url { try? data.write(to: url, options: .atomic); lastActionMessage = "秒传 JSON 已保存并复制" } else { lastActionMessage = "秒传 JSON 已复制" } } func saveTMDBSettings(key: String, proxyHost: String, proxyPort: String) { tmdbAPIKey = key.trimmingCharacters(in: .whitespacesAndNewlines) tmdbProxyHost = proxyHost.trimmingCharacters(in: .whitespacesAndNewlines) tmdbProxyPort = proxyPort.trimmingCharacters(in: .whitespacesAndNewlines) UserDefaults.standard.set(tmdbAPIKey, forKey: "guangya.tmdbAPIKey") UserDefaults.standard.set(tmdbProxyHost, forKey: "guangya.tmdbProxyHost") UserDefaults.standard.set(tmdbProxyPort, forKey: "guangya.tmdbProxyPort") lastActionMessage = tmdbAPIKey.isEmpty ? "TMDB 配置已清除" : "TMDB 配置已保存" } func saveTMDBWorkflow(_ workflow: TMDBWorkflowConfig) { tmdbWorkflow = workflow workflow.save() lastActionMessage = "TMDB 工作流配置已保存" } func prepareTMDBJobs(sourceID: String?, recursive: Bool, minimumSizeMB: Int, extensionsText: String) async { guard !tmdbAPIKey.isEmpty else { errorMessage = "请先配置 TMDB API Key"; return } isRunningTMDBJob = true defer { isRunningTMDBJob = false } do { let allowed = Set(extensionsText.lowercased().split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }) 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 } let ext = (file.name as NSString).pathExtension.lowercased() return allowed.isEmpty || allowed.contains(ext) } let subtitles = snapshot.allFiles.filter { !$0.isDirectory && ["srt", "ass", "ssa", "sub", "vtt", "sup"].contains(($0.name as NSString).pathExtension.lowercased()) } tmdbJobs = candidates.map { file in let base = (file.name as NSString).deletingPathExtension let related = subtitles.filter { subtitle in let subtitleBase = (subtitle.name as NSString).deletingPathExtension return normalizedMediaStem(subtitleBase) == normalizedMediaStem(base) } return TMDBJob(id: file.id, file: file, parsed: parseMediaName(file.name), relatedSubtitles: related, match: nil, state: .pending, note: "等待识别") } lastActionMessage = "已建立 \(tmdbJobs.count) 个 TMDB 任务,请先预览匹配结果" } catch { errorMessage = error.localizedDescription } } func matchTMDBJobs() async { guard !tmdbAPIKey.isEmpty, !tmdbJobs.isEmpty else { return } isRunningTMDBJob = true defer { isRunningTMDBJob = false } for index in tmdbJobs.indices { do { let kind: TMDBMediaKind = tmdbWorkflow.mediaKind == .automatic ? (tmdbJobs[index].parsed.isEpisode ? .tv : .automatic) : tmdbWorkflow.mediaKind let candidates = try await tmdbCandidates(query: tmdbJobs[index].parsed.title, mediaKind: kind, year: tmdbJobs[index].parsed.year) tmdbJobs[index].candidates = candidates tmdbJobs[index].selectedCandidateID = nil tmdbJobs[index].match = nil tmdbJobs[index].state = candidates.isEmpty ? .failed : .pending tmdbJobs[index].note = candidates.isEmpty ? "没有可用候选" : "请选择正确的 TMDB 条目" } catch { tmdbJobs[index].state = .failed; tmdbJobs[index].note = error.localizedDescription } } lastActionMessage = "匹配完成:\(tmdbJobs.filter { $0.state == .matched }.count) 成功,\(tmdbJobs.filter { $0.state == .failed }.count) 失败" } func selectTMDBCandidate(jobID: String, candidateID: Int?) { guard let index = tmdbJobs.firstIndex(where: { $0.id == jobID }) else { return } tmdbJobs[index].selectedCandidateID = candidateID tmdbJobs[index].match = tmdbJobs[index].selectedCandidate?.match tmdbJobs[index].state = candidateID == nil ? .skipped : .matched tmdbJobs[index].note = candidateID == nil ? "已跳过" : "已选择,等待确认" } func toggleTMDBApproval(jobID: String) { guard let index = tmdbJobs.firstIndex(where: { $0.id == jobID }), tmdbJobs[index].match != nil else { return } tmdbJobs[index].isApproved.toggle() tmdbJobs[index].note = tmdbJobs[index].isApproved ? "已确认执行" : "等待确认" } func runTMDBJobs(destinationID: String?) async { guard tmdbWorkflow.operation == .organize else { lastActionMessage = "当前为预览模式,不会修改云盘"; return } guard tmdbJobs.contains(where: { $0.isApproved && $0.match != nil }) else { lastActionMessage = "请先手动选择并确认至少一个匹配项"; return } isRunningTMDBJob = true defer { isRunningTMDBJob = false } for index in tmdbJobs.indices where tmdbJobs[index].state == .matched && tmdbJobs[index].isApproved { guard let match = tmdbJobs[index].match else { continue } do { 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. let targetName: String if tmdbJobs[index].parsed.isEpisode { let season = String(format: "S%02d", tmdbJobs[index].parsed.season ?? 0) targetName = safeCloudName("\(baseFolder) \(season) [tmdb-\(match.id)]") } else { targetName = safeCloudName("\(baseFolder) [tmdb-\(match.id)]") } let created = try await api.fsCreateDir(name: targetName, parentID: destinationID) guard let folderID = created.firstStringDeep(["fileId", "id", "resId"]) else { throw GuangyaAPIError.missingField("目标文件夹 ID") } // Metadata first: if it cannot be written, leave the source media untouched. let details = try await api.tmdbDetails(id: match.id, mediaKind: match.mediaType == "tv" ? .tv : .movie, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) if tmdbWorkflow.createNFO { try await uploadTMDBNFO(match, job: tmdbJobs[index], details: details, parentID: folderID) } try await uploadTMDBArtwork(details: details, job: tmdbJobs[index], parentID: folderID) let ext = (tmdbJobs[index].file.name as NSString).pathExtension let mediaName: String if tmdbJobs[index].parsed.isEpisode { mediaName = safeCloudName("\(match.title) - \(String(format: "S%02dE%02d", tmdbJobs[index].parsed.season ?? 0, tmdbJobs[index].parsed.episode ?? 0))\(ext.isEmpty ? "" : ".\(ext)")") } else { let stem = safeCloudName(renderTMDBTemplate(tmdbWorkflow.fileTemplate, match: match, fallback: tmdbJobs[index].file.name)) mediaName = ext.isEmpty || stem.hasSuffix(".\(ext)") ? stem : "\(stem).\(ext)" } if mediaName != tmdbJobs[index].file.name { _ = try await api.fsRename(fileID: tmdbJobs[index].file.id, newName: mediaName) } _ = try await api.fsMove(fileIDs: [tmdbJobs[index].file.id] + tmdbJobs[index].relatedSubtitles.map(\.id), parentID: folderID) tmdbJobs[index].state = .completed; tmdbJobs[index].note = "已整理至 \(targetName),含 \(tmdbJobs[index].relatedSubtitles.count) 个字幕" } catch { tmdbJobs[index].state = .failed; tmdbJobs[index].note = error.localizedDescription } } lastActionMessage = "任务执行完成:\(tmdbJobs.filter { $0.state == .completed }.count) 项已整理" await loadFiles() } private func uploadTMDBNFO(_ match: TMDBMatch, job: TMDBJob, details: JSONValue, parentID: String) async throws { let root = job.parsed.isEpisode ? "episodedetails" : (match.mediaType == "tv" ? "tvshow" : "movie") let genres = (details["genres"]?.arrayValue ?? []).compactMap { $0.firstString(["name"]) }.map { "\($0.xmlEscaped)" }.joined() let cast = (details["credits"]?["cast"]?.arrayValue ?? []).prefix(20).map { person in "\((person.firstString(["name"]) ?? "").xmlEscaped)\((person.firstString(["character"]) ?? "").xmlEscaped)\(person.firstString(["profile_path"]) ?? "")" }.joined() let crew = details["credits"]?["crew"]?.arrayValue ?? [] let directors = crew.filter { $0.firstString(["job"]) == "Director" }.compactMap { $0.firstString(["name"]) }.map { "\($0.xmlEscaped)" }.joined() let rating = details["vote_average"]?.doubleValue.map { "\($0)\(details["vote_count"]?.intValue ?? 0)" } ?? "" let episodeFields = job.parsed.isEpisode ? "\(job.parsed.season ?? 0)\(job.parsed.episode ?? 0)" : "" let title = details.firstString(["title", "name"]) ?? match.title let original = details.firstString(["original_title", "original_name"]) ?? "" let plot = details.firstString(["overview"]) ?? match.overview let premiered = details.firstString(["release_date", "first_air_date"]) ?? match.releaseDate let xml = "<\(root)>\(match.id)\(title.xmlEscaped)\(original.xmlEscaped)\(plot.xmlEscaped)\(premiered)\(rating)\(genres)\(directors)\(cast)\(episodeFields)" let fileName: String if job.parsed.isEpisode { fileName = "\(match.title) - \(String(format: "S%02dE%02d", job.parsed.season ?? 0, job.parsed.episode ?? 0)).nfo" } else { fileName = match.mediaType == "tv" ? "tvshow.nfo" : "movie.nfo" } let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) try xml.data(using: .utf8)?.write(to: url, options: .atomic) defer { try? FileManager.default.removeItem(at: url) } _ = try await api.fileUpload(url: url, parentID: parentID, contentType: "application/xml") } private func uploadTMDBArtwork(details: JSONValue, job: TMDBJob, parentID: String) async throws { func upload(_ path: String?, as name: String) async throws { guard let path, !path.isEmpty else { return } let data = try await api.tmdbImage(path: path, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) let url = FileManager.default.temporaryDirectory.appendingPathComponent(name) try data.write(to: url, options: .atomic); defer { try? FileManager.default.removeItem(at: url) } _ = try await api.fileUpload(url: url, parentID: parentID, contentType: "image/jpeg") } if tmdbWorkflow.downloadPoster { try await upload(details.firstString(["poster_path"]), as: "poster.jpg") } if tmdbWorkflow.downloadBackdrop { try await upload(details.firstString(["backdrop_path"]), as: "fanart.jpg") } if job.parsed.isEpisode, let match = job.match, let season = job.parsed.season, let episode = job.parsed.episode { let episodeDetails = try await api.tmdbEpisodeDetails(seriesID: match.id, season: season, episode: episode, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) try await upload(episodeDetails.firstString(["still_path"]), as: "\(job.match?.title ?? "Episode") - \(String(format: "S%02dE%02d", season, episode))-thumb.jpg") } if tmdbWorkflow.downloadActorImages { for (index, person) in (details["credits"]?["cast"]?.arrayValue ?? []).prefix(12).enumerated() { guard let path = person.firstString(["profile_path"]), let data = try? await api.tmdbImage(path: path, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) else { continue } let actorID = person.firstInt(["id"]) ?? index let name = safeCloudName(person.firstString(["name"]) ?? "actor") let url = FileManager.default.temporaryDirectory.appendingPathComponent("actor-\(actorID)-\(name).jpg") try data.write(to: url, options: .atomic); defer { try? FileManager.default.removeItem(at: url) } _ = try await api.fileUpload(url: url, parentID: parentID, contentType: "image/jpeg") } } } private func renderTMDBTemplate(_ template: String, match: TMDBMatch, fallback: String) -> String { template.replacingOccurrences(of: "{title}", with: match.title.isEmpty ? fallback : match.title) .replacingOccurrences(of: "{year}", with: String(match.releaseDate.prefix(4))) .replacingOccurrences(of: "{tmdbId}", with: String(match.id)) } func parsedTMDBInfo(for file: CloudFile) -> ParsedMediaName { parseMediaName(file.name) } func queryTMDBManually(for file: CloudFile, title: String, year: Int?, mediaKind: TMDBMediaKind) async { guard !tmdbAPIKey.isEmpty else { errorMessage = "请先配置 TMDB API Key"; return } let query = title.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty else { errorMessage = "请输入要查询的中文或英文标题"; return } isRunningTMDBJob = true defer { isRunningTMDBJob = false } do { // “自动” multi-search may contain people/collections; candidates filter // them. If no compatible result remains, retry movie then TV rather // than surfacing the old misleading missing-field error. var candidates = try await tmdbCandidates(query: query, mediaKind: mediaKind, year: year) if candidates.isEmpty, mediaKind == .automatic { candidates = try await tmdbCandidates(query: query, mediaKind: .movie, year: year) if candidates.isEmpty { candidates = try await tmdbCandidates(query: query, mediaKind: .tv, year: year) } } let parsed = parseMediaName(file.name) tmdbJobs = [TMDBJob(id: file.id, file: file, parsed: parsed, candidates: candidates, selectedCandidateID: nil, state: candidates.isEmpty ? .failed : .pending, note: candidates.isEmpty ? "未找到匹配项,请修改标题、年份或手动填写 TMDB ID" : "请选择正确的 TMDB 条目")] if candidates.isEmpty { lastActionMessage = "TMDB 没有找到“\(query)”的可用电影或剧集结果" } } catch { errorMessage = "TMDB 查询失败:\(error.localizedDescription)" } } func recognizeTMDBByID(_ file: CloudFile, tmdbID: Int, mediaKind: TMDBMediaKind) async { guard tmdbID > 0 else { errorMessage = "请输入有效的 TMDB ID"; return } // TMDB search API can accept numeric IDs only through the detail endpoint; // preserve the explicit ID as an auditable manual choice for the job. let match = TMDBMatch(id: tmdbID, title: parseMediaName(file.name).title, mediaType: mediaKind == .tv ? "tv" : "movie", releaseDate: "", overview: "手动指定 TMDB ID") tmdbJobs = [TMDBJob(id: file.id, file: file, parsed: parseMediaName(file.name), match: match, candidates: [], selectedCandidateID: tmdbID, state: .matched, note: "手动指定 TMDB #\(tmdbID)", isApproved: true)] lastActionMessage = "已建立手动 TMDB #\(tmdbID) 刮削任务,请在工具箱确认执行" } func recognizeTMDB(_ file: CloudFile, organize: Bool = false) async { guard !tmdbAPIKey.isEmpty else { lastActionMessage = "请先在工作区设置中保存 TMDB API Key"; return } await perform(organize ? "正在识别并整理…" : "正在识别…") { [self] in let match = try await tmdbMatch(for: file.name) if organize { let created = try await api.fsCreateDir(name: match.title, parentID: folderPath.last?.id) guard let folderID = created.firstStringDeep(["fileId", "id", "resId"]) else { throw GuangyaAPIError.missingField("整理文件夹 ID") } _ = try await api.fsMove(fileIDs: [file.id], parentID: folderID) let metadata = ["tmdbId": match.id, "title": match.title, "mediaType": match.mediaType, "releaseDate": match.releaseDate, "overview": match.overview] as [String: Any] let metadataURL = FileManager.default.temporaryDirectory.appendingPathComponent("tmdb-\(match.id).json") let data = try JSONSerialization.data(withJSONObject: metadata, options: [.prettyPrinted, .sortedKeys]) try data.write(to: metadataURL, options: .atomic) defer { try? FileManager.default.removeItem(at: metadataURL) } _ = try await api.fileUpload(url: metadataURL, parentID: folderID, contentType: "application/json") lastActionMessage = "已整理到「\(match.title)」并上传刮削信息" await loadFiles() } else { lastActionMessage = "识别结果:\(match.title)\(match.releaseDate.isEmpty ? "" : "(\(match.releaseDate.prefix(4)))")" } } } func recognizeTMDBFolder(_ folder: CloudFile) async { guard !tmdbAPIKey.isEmpty else { lastActionMessage = "请先在工作区设置中保存 TMDB API Key"; return } await perform("正在扫描文件夹并识别…") { [self] in var pending = [folder.id] var media: [CloudFile] = [] while let parentID = pending.popLast() { let items = extractFiles(from: try await api.fsFiles(parentID: parentID)) for item in items { if item.isDirectory { pending.append(item.id) } else { media.append(item) } } } var matches: [String] = [] for file in media { if let match = try? await tmdbMatch(for: file.name) { matches.append(match.title) } } lastActionMessage = matches.isEmpty ? "当前文件夹及子文件夹没有识别结果" : "识别到 \(matches.count) 个媒体文件:\n" + matches.prefix(8).joined(separator: "、") } } func findSimilarFolders() { let folders = files.filter(\.isDirectory) let groups = Dictionary(grouping: folders, by: { normalizedFolderName($0.name) }) similarFolders = groups.values.filter { $0.count > 1 }.flatMap { $0 } lastActionMessage = similarFolders.isEmpty ? "当前目录没有相似文件夹" : "找到 \(similarFolders.count) 个相似文件夹" } func logout() { stopQRLogin() countdownTask?.cancel() listResponseTask?.cancel() detailEnrichmentTask?.cancel() scanTask?.cancel() detailRequestTasks.values.forEach { $0.cancel() } activeListRequestID = UUID() detailEnrichmentGeneration = UUID() countdownTask = nil listResponseTask = nil scanTask = nil detailRequestTasks = [:] detailCache = [:] detailEnrichmentTask = nil detailLoadingIDs = [] sizeLoadingCompleted = 0 sizeLoadingTotal = 0 UserDefaults.standard.removeObject(forKey: "guangya.accessToken") UserDefaults.standard.removeObject(forKey: "guangya.refreshToken") isSignedIn = false files = [] folderPath = [] detail = nil detailFileID = nil detailErrorMessage = "" scanResult = nil liveScanItems = [] statusMessage = "" } 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 { handleAuthorizationExpiry() return } persistTokens() async let userResult: JSONValue? = try? await api.userInfo() async let fileResult: JSONValue? = try? await filesResponse() if let result = await userResult { user = UserProfile(json: result) } else { statusMessage = "登录状态已恢复" } if let result = await fileResult { 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. } else { errorMessage = "文件列表加载失败,请点击刷新重试" } } private func loadFiles(force: Bool = false) async { guard force || !isLoadingFiles else { return } if force { listResponseTask?.cancel() } let requestID = UUID() activeListRequestID = requestID isLoadingFiles = true defer { if activeListRequestID == requestID { isLoadingFiles = false; listResponseTask = nil } } let task = Task { [weak self] in guard let self else { throw CancellationError() } return try await self.filesResponse() } listResponseTask = task do { let result = try await task.value guard !Task.isCancelled, activeListRequestID == requestID else { return } 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. if files.isEmpty { statusMessage = "当前文件夹暂无文件" } else if statusMessage == "当前文件夹暂无文件" { statusMessage = "" } } catch is CancellationError { } catch { guard activeListRequestID == requestID else { return } if isAuthorizationExpiry(error) { handleAuthorizationExpiry() } else { errorMessage = error.localizedDescription } } } private func isAuthorizationExpiry(_ error: Error) -> Bool { if case GuangyaAPIError.authorizationExpired = error { return true } return false } private func persistTokens() { let defaults = UserDefaults.standard defaults.set(api.accessToken, forKey: "guangya.accessToken") if let refresh = api.refreshTokenValue { defaults.set(refresh, forKey: "guangya.refreshToken") } } private func handleAuthorizationExpiry() { guard !isHandlingAuthorizationExpiry else { return } isHandlingAuthorizationExpiry = true api.clearTokens() logout() isHandlingAuthorizationExpiry = false } private func updatePaging(from response: JSONValue) { let total = response.firstIntDeep(["total", "totalCount", "count"]) ?? files.count let pages = max(1, Int(ceil(Double(total) / Double(max(pageSize, 1))))) totalPages = pages if currentPage >= pages { currentPage = pages - 1 } } private func filesResponse() async throws -> JSONValue { switch section { case .files: return try await api.fsFiles(parentID: folderPath.last?.id, page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue) case .recentViewed: return try await api.recentViewed(pageSize: pageSize) case .recentRestored: return try await api.recentRestored(pageSize: pageSize) case .photos: return try await api.fsFiles(parentID: "*", page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue, fileTypes: [1], resType: 1) case .videos: return try await api.fsFiles(parentID: "*", page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue, fileTypes: [2], resType: 1) case .audio: return try await api.fsFiles(parentID: "*", page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue, fileTypes: [3], resType: 1, needPlayRecord: true) case .documents: return try await api.fsFiles(parentID: "*", page: currentPage, pageSize: pageSize, orderBy: serverSort.apiOrderBy, sortType: serverSortDirection.rawValue, fileTypes: [4], resType: 1) case .recycle: return try await api.fsFiles(page: currentPage, pageSize: pageSize, orderBy: 10, dirType: 4) case .cloud, .shares: return .object([:]) } } private func tmdbCandidates(for name: String, mediaKind: TMDBMediaKind = .automatic) async throws -> [TMDBCandidate] { try await tmdbCandidates(query: cleanedTMDBQuery(name), mediaKind: mediaKind, year: nil) } private func tmdbCandidates(query: String, mediaKind: TMDBMediaKind, year: Int?) async throws -> [TMDBCandidate] { let response = try await api.tmdbSearch(query: query, apiKey: tmdbAPIKey, mediaKind: mediaKind, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) return (response["results"]?.arrayValue ?? []).compactMap { TMDBCandidate(json: $0, forcedKind: mediaKind == .automatic ? nil : mediaKind) } } private func tmdbMatch(for name: String, mediaKind: TMDBMediaKind = .automatic) async throws -> TMDBMatch { guard let match = try await tmdbCandidates(for: name, mediaKind: mediaKind).first?.match else { throw GuangyaAPIError.missingField("TMDB 识别结果") } return match } private func parseMediaName(_ name: String) -> ParsedMediaName { let stem = (name as NSString).deletingPathExtension let normalized = stem.replacingOccurrences(of: "[._]+", with: " ", options: .regularExpression) let episodePatterns = ["(?i)\\bS(\\d{1,2})[ ._-]*E(\\d{1,3})\\b", "(?i)\\b(\\d{1,2})x(\\d{1,3})\\b", "(?i)第\\s*(\\d{1,2})\\s*季\\s*第?\\s*(\\d{1,3})\\s*[集话]", "(?i)\\b(\\d{1,2})[ ._-](\\d{1,3})\\b"] var season: Int?; var episode: Int? for pattern in episodePatterns { guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)), match.numberOfRanges >= 3 else { continue } season = Int((normalized as NSString).substring(with: match.range(at: 1))); episode = Int((normalized as NSString).substring(with: match.range(at: 2))); break } let yearRegex = try? NSRegularExpression(pattern: "\\b(19\\d{2}|20\\d{2})\\b") let year = yearRegex?.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)).flatMap { Int((normalized as NSString).substring(with: $0.range(at: 1))) } var title = normalized title = title.replacingOccurrences(of: "(?i)\\bS\\d{1,2}[ ._-]*E\\d{1,3}\\b|\\b\\d{1,2}x\\d{1,3}\\b|第\\s*\\d{1,2}\\s*季\\s*第?\\s*\\d{1,3}\\s*[集话]|\\b\\d{1,2}[ ._-]\\d{1,3}\\b", with: " ", options: .regularExpression) title = title.replacingOccurrences(of: "(?i)\\b(19\\d{2}|20\\d{2}|2160p|1080p|720p|480p|web[- ]?dl|webrip|bluray|brrip|remux|x26[45]|h\\.?26[45]|hevc|av1|aac|dts|atmos|hdr10?\\+?|dv|proper|repack|extended|complete|season|中字|简体|繁体|chs|cht|eng)\\b", with: " ", options: .regularExpression) title = title.replacingOccurrences(of: "[\\[\\](){}._-]+", with: " ", options: .regularExpression).replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines) func first(_ pattern: String) -> String? { guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]), let match = regex.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)) else { return nil } return (normalized as NSString).substring(with: match.range) } let resolution = first("\\b(2160p|1080p|720p|480p|4k)\\b") let source = first("\\b(WEB[- ]?DL|WEBRip|BluRay|BDRip|REMUX|HDTV|DVD|UHD)\\b") let videoCodec = first("\\b(x26[45]|h\\.?26[45]|HEVC|AV1|VC-1)\\b") let audio = first("\\b(Atmos|TrueHD|DTS(?:-HD)?|DDP?(?: ?[0-9.]+)?|AAC|FLAC)\\b") let isDiscStructure = normalized.range(of: "(?i)\\b(BDMV|CERTIFICATE|VIDEO_TS|\\.iso)\\b", options: .regularExpression) != nil return ParsedMediaName(title: title.isEmpty ? stem : title, year: year, season: season, episode: episode, isEpisode: season != nil && episode != nil, resolution: resolution, source: source, videoCodec: videoCodec, audio: audio, isDiscStructure: isDiscStructure) } private func normalizedMediaStem(_ name: String) -> String { parseMediaName(name).title.lowercased().replacingOccurrences(of: "\\s+", with: "", options: .regularExpression) } private func safeCloudName(_ name: String) -> String { let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|\\0") let cleaned = name.components(separatedBy: invalid).joined(separator: " ").replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines) return String((cleaned.isEmpty ? "Untitled" : cleaned).prefix(180)) } private func cleanedTMDBQuery(_ name: String) -> String { parseMediaName(name).title } private func normalizedFolderName(_ value: String) -> String { value.lowercased().replacingOccurrences(of: "[\\s._()\\[\\]{}-]+", with: "", options: .regularExpression) } private func schedulePageDetailEnrichment(force: Bool = false) { detailEnrichmentTask?.cancel() detailEnrichmentGeneration = UUID() let generation = detailEnrichmentGeneration let page = files.filter { file in file.isDirectory ? (force || needsMetadata(for: file)) : needsMetadata(for: file) } guard !page.isEmpty else { isLoadingFolderSizes = false detailLoadingIDs = [] sizeLoadingCompleted = 0 sizeLoadingTotal = 0 return } isLoadingFolderSizes = true detailLoadingIDs = Set(page.map(\.id)) sizeLoadingCompleted = 0 sizeLoadingTotal = page.count detailEnrichmentTask = Task { [weak self] in await self?.enrichPageDetails(page, generation: generation) } } 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 func addNext() { 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"]), detail?.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]), detail?.firstInt64Deep(["utime", "ctime"]), detail?.firstIntDeep(["subDirCount"]), detail?.firstIntDeep(["subFileCount"]) ) } } for _ in 0.. 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 { await gate.acquire() defer { Task { await gate.release() } } try Task.checkCancellation() 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 } let value = try await task.value detailCache[fileID] = (value, Date().addingTimeInterval(300)) // 5-minute TTL if detailCache.count > 800 { detailCache = Dictionary(uniqueKeysWithValues: detailCache.filter { $0.value.expiresAt > Date() }) } 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") formatter.dateFormat = "yyyy-MM-dd HH:mm" return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(epoch))) } private nonisolated func extractFiles(from value: JSONValue) -> [CloudFile] { var result: [CloudFile] = [] var seen = Set() func visit(_ value: JSONValue) { if let file = CloudFile(json: value), seen.insert(file.id).inserted { result.append(file) } if let object = value.objectValue { object.values.forEach(visit) } if let array = value.arrayValue { array.forEach(visit) } } visit(value) return result } private func startQRPolling(expiresIn: Int, interval: Int) { qrPollingTask = Task { [weak self] in guard let self else { return } let deadline = Date().addingTimeInterval(TimeInterval(expiresIn)) while !Task.isCancelled && !self.qrToken.isEmpty && Date() < deadline { try? await Task.sleep(for: .seconds(interval)) guard !Task.isCancelled else { return } do { let result = try await self.api.loginQRPoll(token: self.qrToken) if self.api.accessToken.isEmpty == false { self.qrStatus = "扫码成功,正在进入网盘…" self.completeLogin() return } self.qrStatus = result.firstStringDeep(["message", "msg", "statusText"]) ?? "等待扫码" } catch let error as GuangyaAPIError where error.isDeviceAuthorizationPending { self.qrStatus = "等待扫码" } catch let error as GuangyaAPIError where Self.isQRAuthorizationPending(error) { // The account endpoint can return HTTP 400 "Precondition Required" // while the device grant is still waiting for the phone confirmation. self.qrStatus = "已扫码,请在 App 中确认登录" } catch { self.qrStatus = "二维码状态查询失败" self.errorMessage = error.localizedDescription return } } if !Task.isCancelled && Date() >= deadline { self.qrStatus = "二维码已过期,请刷新" } } } private static func isQRAuthorizationPending(_ error: GuangyaAPIError) -> Bool { guard case .http(let status, let message) = error, status == 400 else { return false } let text = message.lowercased() return text.contains("precondition required") || text.contains("authorization_pending") || text.contains("slow_down") } private func startCountdown() { countdownTask?.cancel() codeCountdown = 60 countdownTask = Task { [weak self] in while let self, self.codeCountdown > 0, !Task.isCancelled { try? await Task.sleep(for: .seconds(1)) self.codeCountdown -= 1 } } } private func perform(_ message: String, operation: @escaping @MainActor () async throws -> Void) async { isBusy = true; errorMessage = ""; statusMessage = message do { try await operation() // A request may have silently refreshed the access token. persistTokens() if errorMessage.isEmpty { statusMessage = "" } } catch { if isAuthorizationExpiry(error) { handleAuthorizationExpiry() } else if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == NSURLErrorTimedOut { errorMessage = "网络请求超时:已自动重试。请检查网络或稍后重试。" } else { errorMessage = error.localizedDescription } } isBusy = false } } private extension String { var xmlEscaped: String { replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") } }