import AppKit import Combine import CryptoKit import Foundation /// Shared helper to extract CloudFile objects from a JSON response. /// Used by both AppModel and PanelModel. func extractCloudFiles(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 } /// Shares successful TMDB lookups between episodes from the same release. /// A typical series folder can contain hundreds of files; querying its show /// once is both faster and much less likely to hit a remote rate limit. private actor MediaTMDBCandidateCache { private var values: [String: TMDBCandidate] = [:] func candidate(for key: String) -> TMDBCandidate? { values[key] } func store(_ candidate: TMDBCandidate, for key: String) { values[key] = candidate } } private struct MediaScrapeResult: Sendable { let index: Int let item: MediaLibraryItem let note: String? let reusedExisting: Bool var needsWriteback = false } private struct MediaMetadataWriteEntry: Sendable { let item: MediaLibraryItem let files: MediaSidecarFiles let organizeOnly: Bool } private struct LocalFastTransferCandidate: Sendable { let url: URL let path: String } private struct PreparedFastTransferEntry: Sendable { let index: Int let entry: FastTransferEntry let targetID: String? let details: [String] let retryOf: UUID? } private actor FastTransferNameReservation { private var names = Set() func reserve(_ key: String) -> Bool { names.insert(key).inserted } } private struct FastTransferSession: Codable, Sendable { let entries: [FastTransferEntry] let results: [FastTransferResult] } private enum FastTransferError: LocalizedError { case invalidJSON(String) case unsupportedHash(String) case missingRemoteResource(String) var errorDescription: String? { switch self { case .invalidJSON(let message): return "秒传 JSON 无效:\(message)" case .unsupportedHash(let path): return "\(path) 缺少有效的 etag 或 gcid" case .missingRemoteResource(let path): return "云端未找到可秒传资源:\(path)" } } } struct MediaArtworkAsset: Identifiable, Sendable { let id: String let label: String let data: Data } struct MediaCastMember: Identifiable, Sendable { let id: String let name: String let role: String let imageData: Data? } @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 globalFileSearchResults: [CloudFile] = [] @Published var isGlobalFileSearching = false @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 mediaLibraryTarget: CloudFile? @Published var mediaLibraries: [MediaLibraryDefinition] = [] @Published private(set) var mediaLibraryStatistics = MediaLibraryStatistics() @Published private(set) var mediaLibraryStatisticsByLibraryID: [String: MediaLibraryStatistics] = [:] @Published var mediaLibraryTasks: [MediaLibraryTask] = [] @Published var mediaLibraryLiveItemsByLibraryID: [String: [MediaLibraryItem]] = [:] @Published var mediaLibraryDestination: MediaLibraryDestination = .all @Published var mediaLibrarySearchText = "" @Published private(set) var isBatchRecognizingMedia = false @Published var isMediaLibraryBackupRunning = false @Published var mediaLibraryBackupStatus = "" @Published var mediaCategoryRules = MediaCategoryRule.load() @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 isFastTransferRunning = false @Published private(set) var isFastTransferPaused = false @Published private(set) var isFastTransferCancellationRequested = false @Published private(set) var cancelledFastTransferEntryIDs: Set = [] @Published var fastTransferProgress = FastTransferProgress() @Published private(set) var fastTransferActiveEntries: [FastTransferEntry] = [] @Published var fastTransferEntries: [FastTransferEntry] = [] { didSet { persistFastTransferSession() } } @Published var fastTransferResults: [FastTransferResult] = [] { didSet { persistFastTransferSession() } } @Published var fastTransferConcurrency = 3 { didSet { let normalized = min(max(fastTransferConcurrency, 1), 20) if normalized != fastTransferConcurrency { fastTransferConcurrency = normalized; return } UserDefaults.standard.set(normalized, forKey: "guangya.fastTransferConcurrency") if isFastTransferRunning { resetFastTransferEstimateSample() } } } @Published var mediaScrapeConcurrency = 3 { didSet { let normalized = min(max(mediaScrapeConcurrency, 3), 20) if normalized != mediaScrapeConcurrency { mediaScrapeConcurrency = normalized; return } UserDefaults.standard.set(normalized, forKey: "guangya.mediaScrapeConcurrency") } } @Published var isMediaWritebackEnabled = false { didSet { UserDefaults.standard.set(isMediaWritebackEnabled, forKey: "guangya.mediaWritebackEnabled") } } @Published var isGeneratingFastTransferJSON = false @Published var fastTransferGenerationProgress = FastTransferProgress() @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 let mediaLibraryStore = MediaLibraryStore.shared private var isHandlingAuthorizationExpiry = false private var scanTask: Task? private var scanSessionID: UUID? private var liveScannedFilesByID: [String: CloudFile] = [:] private var globalFileSearchTask: Task? private var globalFileSearchID = UUID() private var mediaLibraryScanTasks: [UUID: Task] = [:] private var mediaMetadataWriteTasks: [UUID: Task] = [:] private var mediaMetadataWriteQueues: [UUID: [MediaMetadataWriteEntry]] = [:] private var mediaMetadataWrittenKeys: [UUID: Set] = [:] private var didAttemptCloudMediaLibraryRestore = false private var normalizedMediaFolderIDs: [UUID: Set] = [:] private var organizedMediaFolderIDs: [UUID: Set] = [:] private var mediaCategoryFolderIDs: [UUID: [String: String]] = [:] private var mediaArtworkAssetCache: [String: [MediaArtworkAsset]] = [:] private var mediaCastCache: [String: [MediaCastMember]] = [:] private var mediaMetadataWriteFailures: [UUID: Int] = [:] private var resumedMediaLibraryTaskIDs: Set = [] private var fastTransferSessionSaveTask: Task? private var fastTransferStartedAt: Date? private var fastTransferElapsedBeforePause: TimeInterval = 0 private var fastTransferEstimateBaselineProcessed = 0 private var fastTransferEstimateBaselineElapsed: TimeInterval = 0 init() { let defaults = UserDefaults.standard fastTransferConcurrency = min(max(defaults.object(forKey: "guangya.fastTransferConcurrency") as? Int ?? 3, 1), 20) mediaScrapeConcurrency = min(max(defaults.object(forKey: "guangya.mediaScrapeConcurrency") as? Int ?? 3, 3), 20) isMediaWritebackEnabled = defaults.object(forKey: "guangya.mediaWritebackEnabled") as? Bool ?? false let access = defaults.string(forKey: "guangya.accessToken") ?? "" let refresh = defaults.string(forKey: "guangya.refreshToken") api = GuangyaAPI(accessToken: access, refreshToken: refresh) restoreFastTransferSession() Task.detached(priority: .utility) { [weak self] in await self?.cleanupDeletedFiles() } mediaLibraryTasks = MediaLibraryTask.loadHistory() persistMediaLibraryTaskHistory() 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 searchFilesGlobally(_ query: String) { globalFileSearchTask?.cancel() let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) let requestID = UUID() globalFileSearchID = requestID guard !normalizedQuery.isEmpty else { globalFileSearchResults = [] isGlobalFileSearching = false return } globalFileSearchResults = [] isGlobalFileSearching = true globalFileSearchTask = Task { [weak self] in guard let self else { return } do { try await Task.sleep(for: .milliseconds(260)) try Task.checkCancellation() let files = try await self.allGlobalSearchFiles() try Task.checkCancellation() guard self.globalFileSearchID == requestID else { return } let matches = files.filter { $0.name.localizedCaseInsensitiveContains(normalizedQuery) || $0.cloudPath.localizedCaseInsensitiveContains(normalizedQuery) } self.globalFileSearchResults = matches.sorted { if $0.isDirectory != $1.isDirectory { return $0.isDirectory } return $0.name.localizedStandardCompare($1.name) == .orderedAscending } self.isGlobalFileSearching = false self.globalFileSearchTask = nil } catch is CancellationError { // A newer keystroke has superseded this query. } catch { guard self.globalFileSearchID == requestID else { return } self.isGlobalFileSearching = false self.globalFileSearchTask = nil self.errorMessage = error.localizedDescription } } } func clearGlobalFileSearch() { globalFileSearchTask?.cancel() globalFileSearchTask = nil globalFileSearchID = UUID() globalFileSearchResults = [] isGlobalFileSearching = false } 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 visibleFiles = Array(Dictionary(grouping: files + globalFileSearchResults, by: \.id).compactMap { $0.value.first }) let targets = visibleFiles.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) } globalFileSearchResults.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 } globalFileSearchResults.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() } } /// Upload a local file to a specific folder by name (finds or creates it). func uploadBeta(fileAt localURL: URL, toFolder folderName: String) async { await perform("正在上传 \(localURL.lastPathComponent)…") { [self] in // List root to find or create target folder let response = try await api.fsFiles(parentID: nil, page: 0, pageSize: 200) let existing = extractFiles(from: response) var targetID: String? = existing.first { $0.name == folderName && $0.isDirectory }?.id if targetID == nil { let created = try await api.fsCreateDir(name: folderName, parentID: nil) let createdFiles = extractFiles(from: created) targetID = createdFiles.first?.id } guard let parentID = targetID else { lastActionMessage = "无法创建文件夹 \(folderName)" return } _ = try await api.fileUpload(url: localURL, parentID: parentID) lastActionMessage = "已上传 \(localURL.lastPathComponent) 到 \(folderName)/" } } func parseFastTransferJSON(_ text: String) throws -> [FastTransferEntry] { guard let data = text.data(using: .utf8) else { throw FastTransferError.invalidJSON("不是 UTF-8 文本") } let value = try JSONSerialization.jsonObject(with: data) let values: [[String: Any]] if let object = value as? [String: Any], let files = object["files"] as? [[String: Any]] { values = files } else if let files = value as? [[String: Any]] { values = files } else { throw FastTransferError.invalidJSON("顶层需要包含 files 数组") } var entries: [FastTransferEntry] = [] for (index, item) in values.enumerated() { let name = Self.fastTransferString(in: item, keys: ["name", "fileName", "filename"]) let declaredPath = Self.fastTransferString(in: item, keys: ["path", "filePath", "filepath"]) let path = Self.normalizedFastTransferPath(declaredPath ?? name ?? "") guard !path.isEmpty, path != "." else { throw FastTransferError.invalidJSON("第 \(index + 1) 项缺少 path 或 name") } let size = Self.fastTransferSize(in: item) guard size >= 0 else { throw FastTransferError.invalidJSON("第 \(index + 1) 项 size 无效") } let rawETag = Self.fastTransferString(in: item, keys: ["etag", "eTag", "md5"]) let rawGCID = Self.fastTransferString(in: item, keys: ["gcid", "gcId"]) let rawHash = Self.fastTransferString(in: item, keys: ["hash"]) let etag = Self.validatedFastTransferMD5(rawETag ?? rawHash) let gcid = Self.validatedFastTransferHash(rawGCID ?? rawHash, length: 40) guard etag != nil || gcid != nil else { throw FastTransferError.unsupportedHash(path) } entries.append(FastTransferEntry(path: path, size: size, etag: etag, gcid: gcid)) } guard !entries.isEmpty else { throw FastTransferError.invalidJSON("files 不能为空") } return entries } func fastTransferJSON(for entries: [FastTransferEntry]) throws -> String { let files: [[String: Any]] = entries.map { entry in var value: [String: Any] = ["path": entry.path, "size": entry.size] if let gcid = entry.gcid { value["gcid"] = gcid } if let etag = entry.etag { value["etag"] = etag } return value } let data = try JSONSerialization.data(withJSONObject: ["files": files], options: [.prettyPrinted, .sortedKeys]) guard let text = String(data: data, encoding: .utf8) else { throw FastTransferError.invalidJSON("无法生成文本") } return text } func generateFastTransferEntries(from urls: [URL]) async throws -> [FastTransferEntry] { guard !urls.isEmpty, !isGeneratingFastTransferJSON else { return [] } isGeneratingFastTransferJSON = true fastTransferGenerationProgress = FastTransferProgress() defer { isGeneratingFastTransferJSON = false } let candidates = try await Task.detached(priority: .userInitiated) { try Self.fastTransferLocalCandidates(from: urls) }.value fastTransferGenerationProgress.total = candidates.count var entries: [FastTransferEntry] = [] for candidate in candidates { try Task.checkCancellation() fastTransferGenerationProgress.currentName = candidate.path do { let hashes = try await Task.detached(priority: .userInitiated) { try Self.calculateLocalHashes(at: candidate.url) }.value entries.append(FastTransferEntry(path: candidate.path, size: Self.localFileSize(at: candidate.url), etag: hashes.md5, gcid: hashes.gcid)) fastTransferGenerationProgress.completed += 1 } catch { fastTransferGenerationProgress.failed += 1 } } return entries } func importFastTransferEntries( _ entries: [FastTransferEntry], parentID: String?, createDirectories: Bool, skipExisting: Bool, retryingResultIDs: [UUID: UUID] = [:] ) async -> [FastTransferResult] { guard !entries.isEmpty, !isFastTransferRunning else { return [] } isFastTransferRunning = true isFastTransferPaused = false isFastTransferCancellationRequested = false cancelledFastTransferEntryIDs = [] fastTransferProgress = FastTransferProgress(total: entries.count) fastTransferActiveEntries = [] fastTransferElapsedBeforePause = 0 fastTransferStartedAt = Date() resetFastTransferEstimateSample() defer { if let startedAt = fastTransferStartedAt { fastTransferElapsedBeforePause += Date().timeIntervalSince(startedAt) } fastTransferStartedAt = nil clearFastTransferActiveEntries() isFastTransferPaused = false isFastTransferCancellationRequested = false cancelledFastTransferEntryIDs.removeAll() isFastTransferRunning = false } var indexedResults: [(Int, FastTransferResult)] = [] var nextEntryIndex = 0 var shouldStopSubmitting = false let nameReservations = FastTransferNameReservation() func recordPreparationResult(_ index: Int, _ result: FastTransferResult) { indexedResults.append((index, result)) fastTransferResults.append(result) switch result.state { case .imported: fastTransferProgress.completed += 1 fastTransferProgress.imported += 1 case .skipped: fastTransferProgress.completed += 1 fastTransferProgress.skipped += 1 case .failed: fastTransferProgress.failed += 1 case .cancelled: fastTransferProgress.cancelled += 1 } } func nextQueuedEntry() async -> (Int, FastTransferEntry)? { while nextEntryIndex < entries.count && !shouldStopSubmitting { let index = nextEntryIndex let entry = entries[index] nextEntryIndex += 1 if index > 0 && index.isMultiple(of: 32) { await Task.yield() } if cancelledFastTransferEntryIDs.contains(entry.id) { let result = FastTransferResult(entry: entry, state: .cancelled, message: "任务已取消", taskID: nil, targetID: nil, details: ["任务在提交前被取消"], retryOf: retryingResultIDs[entry.id]) recordPreparationResult(index, result) cancelledFastTransferEntryIDs.remove(entry.id) continue } guard await waitForFastTransferPermission() else { shouldStopSubmitting = true return nil } return (index, entry) } return nil } let fastTransferAPI = api await withTaskGroup(of: (Int, FastTransferResult).self) { group in var activeUploadCount = 0 for _ in 0.. TimeInterval? { let processed = fastTransferProgress.processed let processedSinceBaseline = processed - fastTransferEstimateBaselineProcessed guard isFastTransferRunning, processedSinceBaseline > 0, fastTransferProgress.total > processed else { return nil } let elapsed = fastTransferElapsed(at: date) - fastTransferEstimateBaselineElapsed guard elapsed >= 1 else { return nil } return max(0, Double(fastTransferProgress.total - processed) * elapsed / Double(processedSinceBaseline)) } private func fastTransferElapsed(at date: Date = Date()) -> TimeInterval { fastTransferElapsedBeforePause + (fastTransferStartedAt.map { date.timeIntervalSince($0) } ?? 0) } private func resetFastTransferEstimateSample() { fastTransferEstimateBaselineProcessed = fastTransferProgress.processed fastTransferEstimateBaselineElapsed = fastTransferElapsed() } func cancelFastTransfer() { guard isFastTransferRunning else { return } isFastTransferCancellationRequested = true isFastTransferPaused = false } func cancelFastTransferEntry(_ entryID: UUID) { guard isFastTransferRunning, !fastTransferProgress.activeEntryIDs.contains(entryID) else { return } cancelledFastTransferEntryIDs.insert(entryID) } private func addFastTransferActiveEntry(_ entry: FastTransferEntry) { var progress = fastTransferProgress progress.activeEntryIDs.insert(entry.id) fastTransferProgress = progress if !fastTransferActiveEntries.contains(where: { $0.id == entry.id }) { fastTransferActiveEntries.append(entry) } } private func removeFastTransferActiveEntry(_ entryID: UUID) { var progress = fastTransferProgress progress.activeEntryIDs.remove(entryID) fastTransferProgress = progress fastTransferActiveEntries.removeAll { $0.id == entryID } } private func clearFastTransferActiveEntries() { var progress = fastTransferProgress progress.activeEntryIDs.removeAll() fastTransferProgress = progress fastTransferActiveEntries.removeAll() } private func waitForFastTransferPermission() async -> Bool { while isFastTransferPaused && !isFastTransferCancellationRequested && !Task.isCancelled { try? await Task.sleep(for: .milliseconds(180)) } return !isFastTransferCancellationRequested && !Task.isCancelled } func clearFastTransferResults() { guard !isFastTransferRunning else { return } fastTransferResults.removeAll() } func clearFastTransferSession() { guard !isFastTransferRunning else { return } fastTransferEntries.removeAll() fastTransferResults.removeAll() fastTransferProgress = FastTransferProgress() } var hasPendingFastTransferEntries: Bool { let completedIDs = Set(fastTransferResults.map(\.entry.id)) return fastTransferEntries.contains { !completedIDs.contains($0.id) } } func replaceFastTransferEntries(_ entries: [FastTransferEntry]) { guard !isFastTransferRunning else { return } fastTransferResults.removeAll() fastTransferEntries = entries fastTransferProgress = FastTransferProgress(total: entries.count) persistFastTransferSession(immediately: true) } private static var fastTransferSessionURL: URL? { guard let supportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } let directory = supportDirectory.appendingPathComponent("Guangya", isDirectory: true) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) return directory.appendingPathComponent("fast-transfer-session.json", isDirectory: false) } private func persistFastTransferSession(immediately: Bool = false) { fastTransferSessionSaveTask?.cancel() guard let url = Self.fastTransferSessionURL else { return } let session = FastTransferSession(entries: fastTransferEntries, results: fastTransferResults) fastTransferSessionSaveTask = Task.detached(priority: .utility) { if !immediately { try? await Task.sleep(for: .milliseconds(200)) } guard !Task.isCancelled else { return } if session.entries.isEmpty && session.results.isEmpty { try? FileManager.default.removeItem(at: url) return } guard let data = try? JSONEncoder().encode(session), !Task.isCancelled else { return } try? data.write(to: url, options: .atomic) } } private func restoreFastTransferSession() { guard let url = Self.fastTransferSessionURL, let data = try? Data(contentsOf: url), let session = try? JSONDecoder().decode(FastTransferSession.self, from: data) else { return } let entryIDs = Set(session.entries.map(\.id)) fastTransferEntries = session.entries fastTransferResults = session.results.filter { entryIDs.contains($0.entry.id) } let latestResults = fastTransferResults.reduce(into: [UUID: FastTransferResult]()) { $0[$1.entry.id] = $1 } var progress = FastTransferProgress(total: fastTransferEntries.count) for result in latestResults.values { switch result.state { case .imported: progress.completed += 1 progress.imported += 1 case .skipped: progress.completed += 1 progress.skipped += 1 case .failed: progress.failed += 1 case .cancelled: progress.cancelled += 1 } } fastTransferProgress = progress } /// Remove imported/skipped results whose target files no longer exist on /// the cloud. Runs once after session restore to keep the queue accurate /// without blocking the UI. private func cleanupDeletedFiles() async { guard !api.accessToken.isEmpty else { return } let resultsToCheck = await MainActor.run { fastTransferResults.filter { $0.state == .imported || $0.state == .skipped } } var deletedIDs = Set() for result in resultsToCheck { guard let targetID = result.targetID else { continue } do { let detail = try await api.fsDetail(fileID: targetID) if let code = detail.firstIntDeep(["code"]), code != 0 { deletedIDs.insert(result.entry.id) } } catch { deletedIDs.insert(result.entry.id) } try? await Task.sleep(for: .milliseconds(150)) } guard !deletedIDs.isEmpty else { return } await MainActor.run { let idsToRemove = deletedIDs fastTransferResults.removeAll { idsToRemove.contains($0.entry.id) } fastTransferEntries.removeAll { idsToRemove.contains($0.id) } let latestResults = fastTransferResults.reduce(into: [UUID: FastTransferResult]()) { $0[$1.entry.id] = $1 } var progress = FastTransferProgress(total: fastTransferEntries.count) for result in latestResults.values { switch result.state { case .imported: progress.completed += 1; progress.imported += 1 case .skipped: progress.completed += 1; progress.skipped += 1 case .failed: progress.failed += 1 case .cancelled: progress.cancelled += 1 } } fastTransferProgress = progress } } private nonisolated static func prepareAndPerformFastTransfer( _ entry: FastTransferEntry, parentID: String?, createDirectories: Bool, skipExisting: Bool, retryOf: UUID?, reservations: FastTransferNameReservation, api: GuangyaAPI ) async -> FastTransferResult { var details: [String] = [] do { var targetID = parentID let components = entry.directoryPath .split(separator: "/") .map(String.init) .filter { !$0.isEmpty && $0 != "." } if !components.isEmpty { guard createDirectories else { throw FastTransferError.invalidJSON("\(entry.path) 包含目录,请开启自动创建多级目录") } var dirCache: [String: String] = [:] var currentID = parentID for component in components { guard component != ".." else { throw FastTransferError.invalidJSON("目录不能包含 ..") } let cacheKey = "\(currentID ?? "__root__")/\(component)" if let cached = dirCache[cacheKey] { currentID = cached continue } let children = try await Self.fastTransferListChildren(parentID: currentID, api: api) if let existing = children.first(where: { $0.name == component && $0.isDirectory }) { currentID = existing.id } else if children.contains(where: { $0.name == component }) { throw FastTransferError.invalidJSON("\(component) 已被同名文件占用") } else { let created = try await api.fsCreateDir(name: component, parentID: currentID) if let id = created.firstStringDeep(["fileId", "file_id", "id", "resId"]) { currentID = id } else if let refreshed = try await Self.fastTransferListChildren(parentID: currentID, api: api).first(where: { $0.name == component && $0.isDirectory }) { currentID = refreshed.id } else { throw GuangyaAPIError.missingField("目录 \(component) ID") } } if let currentID { dirCache[cacheKey] = currentID } } targetID = currentID } if skipExisting { let siblings = try await Self.fastTransferListChildren(parentID: targetID, api: api) if siblings.contains(where: { $0.name == entry.name && !$0.isDirectory }) { details.append("目标位置已存在同名文件") return FastTransferResult(entry: entry, state: .skipped, message: "目标位置已存在同名文件", taskID: nil, targetID: targetID, details: details, retryOf: retryOf) } } let reservationKey = "\(targetID ?? "__root__")/\(entry.name)" guard await reservations.reserve(reservationKey) else { details.append("同名文件正在并发处理中,跳过") return FastTransferResult(entry: entry, state: .skipped, message: "同名文件正在并发处理中", taskID: nil, targetID: targetID, details: details, retryOf: retryOf) } let prepared = PreparedFastTransferEntry(index: 0, entry: entry, targetID: targetID, details: details, retryOf: retryOf) return await Self.performPreparedFastTransfer(prepared, api: api) } catch { details.append("准备阶段失败:\(error.localizedDescription)") return FastTransferResult(entry: entry, state: .failed, message: error.localizedDescription, taskID: nil, targetID: nil, details: details, retryOf: retryOf) } } private nonisolated static func fastTransferListChildren(parentID: String?, api: GuangyaAPI) async throws -> [CloudFile] { var page = 0 var values: [CloudFile] = [] while true { let response = try await api.fsFiles(parentID: parentID, page: page, pageSize: 1_000) let batch = await Task.detached(priority: .utility) { Self.extractFastTransferFiles(from: response) }.value values += batch let total = response.firstIntDeep(["total", "totalCount", "count"]) if batch.count < 1_000 || (total != nil && values.count >= total!) { break } page += 1 } return await Task.detached(priority: .utility) { Array(Dictionary(grouping: values, by: \.id).compactMap { $0.value.first }) }.value } private nonisolated static func performPreparedFastTransfer(_ prepared: PreparedFastTransferEntry, api: GuangyaAPI) async -> FastTransferResult { var details = prepared.details var taskID: String? do { if let etag = prepared.entry.etag, !etag.isEmpty { details.append("正在按 MD5 秒传协议校验资源") let token = try await api.flashTransferToken(name: prepared.entry.name, fileSize: prepared.entry.size, parentID: prepared.targetID, md5: etag) if token.firstIntDeep(["code"]) == 156 { details.append("MD5 \(etag) 已命中云端资源") return FastTransferResult(entry: prepared.entry, state: .imported, message: "秒传成功", taskID: nil, targetID: prepared.targetID, details: details, retryOf: prepared.retryOf) } if let unresolvedTaskID = token["data"]?.firstString(["taskId", "task_id"]) { taskID = unresolvedTaskID try? await api.deleteUploadTask(taskIDs: [unresolvedTaskID]) details.append("MD5 未命中云端资源,已清理预检任务") } throw FastTransferError.missingRemoteResource(prepared.entry.path) } else { guard let gcid = prepared.entry.gcid else { throw FastTransferError.unsupportedHash(prepared.entry.path) } details.append("正在按 GCID 秒传协议校验资源") let token = try await api.flashTransferGCIDToken(name: prepared.entry.name, fileSize: prepared.entry.size, parentID: prepared.targetID, gcid: gcid) if token.firstIntDeep(["code"]) == 156 { details.append("GCID \(gcid) 已命中云端资源") return FastTransferResult(entry: prepared.entry, state: .imported, message: "秒传成功", taskID: nil, targetID: prepared.targetID, details: details, retryOf: prepared.retryOf) } if let unresolvedTaskID = token["data"]?.firstString(["taskId", "task_id"]) { taskID = unresolvedTaskID try? await api.deleteUploadTask(taskIDs: [unresolvedTaskID]) details.append("GCID 未命中云端资源,已清理预检任务") } throw FastTransferError.missingRemoteResource(prepared.entry.path) } } catch is CancellationError { details.append("任务已终止") return FastTransferResult(entry: prepared.entry, state: .cancelled, message: "任务已终止", taskID: taskID, targetID: prepared.targetID, details: details, retryOf: prepared.retryOf) } catch { if Task.isCancelled || (error as? URLError)?.code == .cancelled { details.append("任务已终止") return FastTransferResult(entry: prepared.entry, state: .cancelled, message: "任务已终止", taskID: taskID, targetID: prepared.targetID, details: details, retryOf: prepared.retryOf) } details.append("失败:\(error.localizedDescription)") return FastTransferResult(entry: prepared.entry, state: .failed, message: error.localizedDescription, taskID: taskID, targetID: prepared.targetID, details: details, retryOf: prepared.retryOf) } } private nonisolated static func waitForFastTransferCompletion(taskID: String, api: GuangyaAPI) async throws { for _ in 0..<30 { try Task.checkCancellation() let result = try await api.uploadInfo(taskID: taskID) if let data = result["data"], data != .null { return } try await Task.sleep(for: .seconds(1)) } throw GuangyaAPIError.http(status: 0, message: "云端秒传任务确认超时") } 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.isVideo, 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)) } } // MARK: - External Player Support /// Supported external players: (display name, bundle identifier) static let supportedPlayers: [(name: String, bundleID: String)] = [ ("IINA", "com.colliderli.iina"), ("VLC", "org.videolan.vlc"), ("Infuse", "com.firecore.Infuse"), ("nPlayer", "com.nplayer.nplayer"), ("Movist Pro", "com.movist.MovistPro"), ("VidHub", "com.mac.utility.media.hub"), ("Forward", "flux.inchmade.app"), ("SenPlayer", "com.wuziqi.SenPlayer"), ("PotPlayer", "com.kakao.PotPlayer"), ("mpv", "io.mpv"), ] /// Returns the list of players actually installed on this Mac. static var installedPlayers: [(name: String, bundleID: String)] { supportedPlayers.filter { NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0.bundleID) != nil } } /// Open a file with a specific named player (must be in supportedPlayers). func openFile(_ file: CloudFile, withPlayerNamed playerName: String) async { await perform("正在使用 \(playerName) 播放…") { [self] in guard let player = Self.supportedPlayers.first(where: { $0.name == playerName }), let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: player.bundleID) else { lastActionMessage = "未找到播放器 \(playerName)" return } let url = try await remoteURL(for: file) try await NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: .init()) } } /// Play with the first available external player (auto-detect). func playWithExternalPlayer(_ file: CloudFile) async { await perform("正在交给外部播放器…") { [self] in let url = try await remoteURL(for: file) for p in Self.supportedPlayers { if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: p.bundleID) { try await NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: .init()) return } } NSWorkspace.shared.open(url) } } 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 loadMediaLibraries() async { if !didAttemptCloudMediaLibraryRestore, await mediaLibraryStore.isEmpty() { didAttemptCloudMediaLibraryRestore = true _ = try? await restoreMediaLibraryBackupFromCloud(reportStatus: false) } mediaLibraries = await mediaLibraryStore.libraries() await refreshMediaLibraryStatistics() } func refreshMediaLibraryStatistics() async { mediaLibraryStatistics = await mediaLibraryStore.statistics() mediaLibraryStatisticsByLibraryID = await mediaLibraryStore.statisticsByLibraryID() } func createMediaLibrary(name: String, rootID: String?, rootPath: String, kind: MediaLibraryKind, recursive: Bool, minimumSizeMB: Int = 50) async -> MediaLibraryDefinition { await createMediaLibrary(name: name, sources: [MediaLibrarySource(rootID: rootID, path: rootPath)], kind: kind, recursive: recursive, minimumSizeMB: minimumSizeMB) } func createMediaLibrary(name: String, sources: [MediaLibrarySource], kind: MediaLibraryKind, recursive: Bool, minimumSizeMB: Int = 50) async -> MediaLibraryDefinition { let library = MediaLibraryDefinition(id: UUID().uuidString, name: name.trimmingCharacters(in: .whitespacesAndNewlines), sources: sources, kind: kind, recursive: recursive, minimumSizeMB: minimumSizeMB, updatedAt: nil) await mediaLibraryStore.save(library: library) await loadMediaLibraries() return library } func deleteMediaLibrary(_ library: MediaLibraryDefinition) async { await mediaLibraryStore.delete(libraryID: library.id) await loadMediaLibraries() } func updateMediaLibrary(_ library: MediaLibraryDefinition) async { await mediaLibraryStore.save(library: library) await loadMediaLibraries() } func saveMediaCategoryRules(_ rules: [MediaCategoryRule]) { mediaCategoryRules = rules MediaCategoryRule.save(rules) lastActionMessage = "影视分类规则已保存" } func resetMediaCategoryRules() { saveMediaCategoryRules(MediaCategoryRule.defaults) lastActionMessage = "已恢复默认影视分类" } func cachedMediaLibraryItems(libraryID: String) async -> [MediaLibraryItem] { await mediaLibraryStore.items(libraryID: libraryID) } func cachedMediaLibraryPage(libraryID: String?, limit: Int = 72, offset: Int = 0) async -> [MediaLibraryItem] { await mediaLibraryStore.itemPage(libraryID: libraryID, limit: limit, offset: offset) } func removeLegacyIgnoredMediaItemsIfNeeded() async -> Int { let key = "guangya.zeroPaddedMediaCleanupV1" guard !UserDefaults.standard.bool(forKey: key) else { return 0 } let removed = await mediaLibraryStore.removeZeroPaddedMediaEntries() UserDefaults.standard.set(true, forKey: key) guard !removed.isEmpty else { return 0 } let IDsByLibrary = Dictionary(grouping: removed, by: \.libraryID).mapValues { Set($0.map(\.fileID)) } for (libraryID, ids) in IDsByLibrary { mediaLibraryLiveItemsByLibraryID[libraryID]?.removeAll { ids.contains($0.id) } } await refreshMediaLibraryStatistics() return removed.count } func cachedAllMediaLibraryItems() async -> [MediaLibraryItem] { var items: [MediaLibraryItem] = [] for library in mediaLibraries { items += await mediaLibraryStore.items(libraryID: library.id) } return Array(Dictionary(grouping: items, by: \.id).compactMap { $0.value.first }) } func exportMediaLibraryBackup(to url: URL) async throws { try await mediaLibraryStore.exportBackup(to: url) } func importMediaLibraryBackup(from url: URL) async throws { try await mediaLibraryStore.importBackup(from: url) mediaLibraryLiveItemsByLibraryID = [:] await loadMediaLibraries() } func backupMediaLibraryToCloud() async { guard !isMediaLibraryBackupRunning else { return } isMediaLibraryBackupRunning = true mediaLibraryBackupStatus = "正在导出影视缓存…" defer { isMediaLibraryBackupRunning = false } do { let temporaryURL = FileManager.default.temporaryDirectory .appendingPathComponent("guangya-media-library-\(UUID().uuidString).sqlite3") defer { try? FileManager.default.removeItem(at: temporaryURL) } try await mediaLibraryStore.exportBackup(to: temporaryURL) mediaLibraryBackupStatus = "正在上传云盘备份…" let folderID = try await mediaLibraryBackupFolderID(createIfMissing: true) let existing = extractFiles(from: try await api.fsFiles(parentID: folderID, page: 0, pageSize: 1_000)) .filter { !$0.isDirectory && $0.name == Self.mediaLibraryBackupFilename } if !existing.isEmpty { _ = try await api.fsDelete(fileIDs: existing.map(\.id)) } _ = try await api.fileUpload(url: temporaryURL, parentID: folderID, contentType: "application/octet-stream") mediaLibraryBackupStatus = "云盘备份已更新" } catch { mediaLibraryBackupStatus = "云盘备份失败:\(error.localizedDescription)" } } func restoreMediaLibraryBackupFromCloud() async { guard !isMediaLibraryBackupRunning else { return } isMediaLibraryBackupRunning = true defer { isMediaLibraryBackupRunning = false } do { _ = try await restoreMediaLibraryBackupFromCloud(reportStatus: true) mediaLibraryLiveItemsByLibraryID = [:] await loadMediaLibraries() } catch { mediaLibraryBackupStatus = "云盘恢复失败:\(error.localizedDescription)" } } private static let mediaLibraryBackupFolderName = "光鸭影视备份" private static let mediaLibraryBackupFilename = "media-library.sqlite3" @discardableResult private func restoreMediaLibraryBackupFromCloud(reportStatus: Bool) async throws -> Bool { if reportStatus { mediaLibraryBackupStatus = "正在查找云盘备份…" } guard let folderID = try await mediaLibraryBackupFolderID(createIfMissing: false) else { if reportStatus { mediaLibraryBackupStatus = "云盘没有影视备份" } return false } let backups = extractFiles(from: try await api.fsFiles(parentID: folderID, page: 0, pageSize: 1_000)) .filter { $0.name == Self.mediaLibraryBackupFilename && !$0.isDirectory } guard let backup = backups.first else { if reportStatus { mediaLibraryBackupStatus = "云盘没有影视备份" } return false } if reportStatus { mediaLibraryBackupStatus = "正在下载云盘备份…" } let response = try await api.downloadURL(fileID: backup.id) guard let value = response.firstStringDeep(["url", "downloadUrl", "download_url", "dlink"]), let remoteURL = URL(string: value) else { throw GuangyaAPIError.missingField("影视备份下载地址") } let (data, _) = try await URLSession.shared.data(from: remoteURL) let temporaryURL = FileManager.default.temporaryDirectory .appendingPathComponent("guangya-media-library-restore-\(UUID().uuidString).sqlite3") defer { try? FileManager.default.removeItem(at: temporaryURL) } try data.write(to: temporaryURL, options: Data.WritingOptions.atomic) try await mediaLibraryStore.importBackup(from: temporaryURL) if reportStatus { mediaLibraryBackupStatus = "已从云盘恢复影视缓存" } return true } private func mediaLibraryBackupFolderID(createIfMissing: Bool) async throws -> String? { let rootItems = extractFiles(from: try await api.fsFiles(parentID: nil, page: 0, pageSize: 1_000)) if let folder = rootItems.first(where: { $0.isDirectory && $0.name == Self.mediaLibraryBackupFolderName }) { return folder.id } guard createIfMissing else { return nil } let created = try await api.fsCreateDir(name: Self.mediaLibraryBackupFolderName, parentID: nil) if let id = created.firstStringDeep(["fileId", "file_id", "id", "resId"]) { return id } let refreshed = extractFiles(from: try await api.fsFiles(parentID: nil, page: 0, pageSize: 1_000)) return refreshed.first(where: { $0.isDirectory && $0.name == Self.mediaLibraryBackupFolderName })?.id } func manualMediaCandidates(query: String, mediaKind: TMDBMediaKind = .automatic, year: Int? = nil) async throws -> [TMDBCandidate] { let title = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { return [] } guard !tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw GuangyaAPIError.missingField("TMDB API Key") } let response = try await api.tmdbSearch(query: title, apiKey: tmdbAPIKey, mediaKind: mediaKind, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort, year: year) var seen = Set() return (response["results"]?.arrayValue ?? []).compactMap { TMDBCandidate(json: $0) } .filter { seen.insert("\($0.mediaType.rawValue)-\($0.id)").inserted } .sorted { left, right in let expectedYear = year.map(String.init) let leftMatches = String(left.releaseDate.prefix(4)) == expectedYear let rightMatches = String(right.releaseDate.prefix(4)) == expectedYear return leftMatches && !rightMatches } } func mediaArtworkAssets(for item: MediaLibraryItem) async -> [MediaArtworkAsset] { guard let tmdbID = item.tmdbID, let mediaKind = item.mediaKind, !tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return [] } let cacheKey = "\(mediaKind.rawValue)-\(tmdbID)" if let cached = mediaArtworkAssetCache[cacheKey] { return cached } do { let details = try await api.tmdbDetails(id: tmdbID, mediaKind: mediaKind, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) func paths(in collection: String, limit: Int) -> [String] { let images = details["images"]?[collection]?.arrayValue ?? [] let languageRank: [String?: Int] = ["zh": 0, "zh-CN": 0, nil: 1, "en": 2] let ordered = images.sorted { (languageRank[$0.firstString(["iso_639_1"])] ?? 3) < (languageRank[$1.firstString(["iso_639_1"])] ?? 3) } var unique: [String] = [] for image in ordered { guard let path = image.firstString(["file_path"]), !unique.contains(path) else { continue } unique.append(path) } return Array(unique.prefix(limit)) } var requests: [(path: String, size: String, label: String)] = [] let posters = paths(in: "posters", limit: 6) if let originalPoster = posters.first { requests.append((originalPoster, "original", "原稿海报")) } requests += posters.dropFirst().map { ($0, "w500", "海报") } requests += paths(in: "backdrops", limit: 8).map { ($0, "w1280", "横幅 / 同人画") } requests += paths(in: "logos", limit: 4).map { ($0, "w500", "Logo") } let assets = await withTaskGroup(of: MediaArtworkAsset?.self, returning: [MediaArtworkAsset].self) { group in for request in requests { group.addTask { [api, tmdbAPIKey, tmdbProxyHost, tmdbProxyPort] in guard let data = try? await api.tmdbImage(path: request.path, size: request.size, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) else { return nil } return MediaArtworkAsset(id: "\(request.label)-\(request.path)", label: request.label, data: data) } } var values: [MediaArtworkAsset] = [] for await asset in group { if let asset { values.append(asset) } } return values } await persistMediaArtworkAssets(assets, for: item) mediaArtworkAssetCache[cacheKey] = assets return assets } catch { return [] } } func mediaCastMembers(for item: MediaLibraryItem) async -> [MediaCastMember] { guard let tmdbID = item.tmdbID, let mediaKind = item.mediaKind, !tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return [] } let mediaKey = item.file.gcid?.isEmpty == false ? item.file.gcid! : item.file.id let cacheKey = "\(mediaKind.rawValue)-\(tmdbID)-\(mediaKey)" if let cached = mediaCastCache[cacheKey] { return cached } do { let details = try await api.tmdbDetails(id: tmdbID, mediaKind: mediaKind, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) let rootID = try await ensureMediaAssetFolder(named: "光鸭影视素材", parentID: nil) let actorsID = try await ensureMediaAssetFolder(named: "演员", parentID: rootID) let storageID = try await ensureMediaAssetFolder(named: safeCloudName(mediaKey), parentID: actorsID) let existing = try await allFiles(parentID: storageID, pageSize: 1_000) var members: [MediaCastMember] = [] for (index, person) in (details["credits"]?["cast"]?.arrayValue ?? []).prefix(12).enumerated() { let personID = person.firstInt(["id"]) ?? index let imageName = "\(personID).jpg" let imageData: Data? if let cachedImage = existing.first(where: { $0.name == imageName }) { imageData = await Self.sidecarData(for: cachedImage, api: api) } else if let imagePath = person.firstString(["profile_path"]), let downloaded = try? await api.tmdbImage(path: imagePath, size: "w500", apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) { imageData = downloaded let directory = FileManager.default.temporaryDirectory.appendingPathComponent("guangya-cast-\(UUID().uuidString)", isDirectory: true) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let url = directory.appendingPathComponent(imageName) try? downloaded.write(to: url, options: .atomic) if FileManager.default.fileExists(atPath: url.path) { _ = try? await api.fileUpload(url: url, parentID: storageID, contentType: "image/jpeg") } try? FileManager.default.removeItem(at: directory) } else { imageData = nil } members.append(MediaCastMember(id: String(personID), name: person.firstString(["name"]) ?? "演员", role: person.firstString(["character"]) ?? "", imageData: imageData)) } mediaCastCache[cacheKey] = members return members } catch { return [] } } private func ensureMediaAssetFolder(named name: String, parentID: String?) async throws -> String { let response = try await api.fsFiles(parentID: parentID, page: 0, pageSize: 1_000) if let existing = extractFiles(from: response).first(where: { $0.isDirectory && $0.name == name }) { return existing.id } let created = try await api.fsCreateDir(name: name, parentID: parentID) if let id = created.firstStringDeep(["fileId", "file_id", "id", "resId"]) { return id } let refreshed = try await api.fsFiles(parentID: parentID, page: 0, pageSize: 1_000) guard let folder = extractFiles(from: refreshed).first(where: { $0.isDirectory && $0.name == name }) else { throw GuangyaAPIError.missingField("素材目录 \(name)") } return folder.id } private func persistMediaArtworkAssets(_ assets: [MediaArtworkAsset], for item: MediaLibraryItem) async { guard !assets.isEmpty else { return } let mediaKey = item.file.gcid?.isEmpty == false ? item.file.gcid! : item.file.id do { let rootID = try await ensureMediaAssetFolder(named: "光鸭影视素材", parentID: nil) let imagesID = try await ensureMediaAssetFolder(named: "图片", parentID: rootID) let storageID = try await ensureMediaAssetFolder(named: safeCloudName(mediaKey), parentID: imagesID) let existing = try await allFiles(parentID: storageID, pageSize: 1_000) for (index, asset) in assets.enumerated() { let name = "\(String(format: "%02d", index + 1))-\(safeCloudName(asset.label)).jpg" guard !existing.contains(where: { $0.name == name }) else { continue } let directory = FileManager.default.temporaryDirectory.appendingPathComponent("guangya-artwork-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent(name) try asset.data.write(to: url, options: .atomic) _ = try await api.fileUpload(url: url, parentID: storageID, contentType: "image/jpeg") } } catch { } } func applyManualMediaCandidate(_ candidate: TMDBCandidate, to item: MediaLibraryItem, preferredLibraryID: String?) async throws -> MediaLibraryItem { guard !tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw GuangyaAPIError.missingField("TMDB API Key") } let updated = try await resolvedManualMediaItem(candidate, replacing: item) try await persistManuallyRecognizedMediaItem(updated, preferredLibraryID: preferredLibraryID) await refreshMediaLibraryStatistics() return updated } /// Re-identifies unrecognized resources in title groups. Episodes of one /// series share one TMDB lookup and then receive the same scraped metadata. func batchRecognizeUnmatchedMediaItems(_ items: [MediaLibraryItem], preferredLibraryID: String?) async -> (recognized: Int, failed: Int) { guard !isBatchRecognizingMedia else { return (0, 0) } let pending = items.filter { !$0.isMatched } guard !pending.isEmpty else { return (0, 0) } guard !tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { errorMessage = "请先配置 TMDB API Key" return (0, pending.count) } isBatchRecognizingMedia = true defer { isBatchRecognizingMedia = false } let grouped = Dictionary(grouping: pending) { item -> String in let parsed = parsedTMDBInfo(for: item) let kind = parsed.isEpisode ? TMDBMediaKind.tv.rawValue : TMDBMediaKind.automatic.rawValue return "\(kind):\(parsed.year.map(String.init) ?? ""):\(Self.normalizedTMDBLookupTitle(parsed.title))" } let groups = Array(grouped.values) let concurrency = min(mediaScrapeConcurrency, groups.count) var iterator = groups.makeIterator() var recognized = 0 var failed = 0 await withTaskGroup(of: (recognized: Int, failed: Int).self) { group in func addNext() { guard let items = iterator.next() else { return } group.addTask { [weak self] in guard let self else { return (0, items.count) } return await self.recognizeUnmatchedMediaGroup(items, preferredLibraryID: preferredLibraryID) } } for _ in 0.. (recognized: Int, failed: Int) { guard let seed = items.first else { return (0, 0) } let parsed = parsedTMDBInfo(for: seed) let preferredKind: TMDBMediaKind = parsed.isEpisode ? .tv : .automatic do { guard let candidate = try await Self.mediaTMDBCandidate(for: parsed, preferredKind: preferredKind, api: api, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) else { return (0, items.count) } let template = try await resolvedManualMediaItem(candidate, replacing: seed) var recognized = 0 for item in items { let updated = MediaLibraryItem(file: item.file, tmdbID: template.tmdbID, title: template.title, originalTitle: template.originalTitle, mediaKind: template.mediaKind, releaseDate: template.releaseDate, overview: template.overview, posterData: template.posterData, backdropData: template.backdropData, hasChineseAudio: item.hasChineseAudio, hasChineseSubtitle: item.hasChineseSubtitle, collectionID: template.collectionID, collectionName: template.collectionName) try await persistManuallyRecognizedMediaItem(updated, preferredLibraryID: preferredLibraryID) recognized += 1 } return (recognized, 0) } catch { return (0, items.count) } } private func resolvedManualMediaItem(_ candidate: TMDBCandidate, replacing item: MediaLibraryItem) async throws -> MediaLibraryItem { let details = try await api.tmdbDetails(id: candidate.id, mediaKind: candidate.mediaType, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) let title = details.firstString(["title", "name"]) ?? candidate.title let originalTitle = details.firstString(["original_title", "original_name"]) ?? candidate.originalTitle let releaseDate = details.firstString(["release_date", "first_air_date"]) ?? candidate.releaseDate let posterPath = Self.preferredArtworkPath(in: details, collection: "posters") ?? details.firstString(["poster_path"]) ?? candidate.posterPath let backdropPath = Self.preferredArtworkPath(in: details, collection: "backdrops") ?? details.firstString(["backdrop_path"]) ?? candidate.backdropPath let posterData: Data? if let posterPath { posterData = try? await api.tmdbImage(path: posterPath, size: "w500", apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) } else { posterData = nil } let backdropData: Data? if let backdropPath { backdropData = try? await api.tmdbImage(path: backdropPath, size: "w1280", apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) } else { backdropData = nil } let collection = details["belongs_to_collection"] return MediaLibraryItem(file: item.file, tmdbID: candidate.id, title: title, originalTitle: originalTitle, mediaKind: candidate.mediaType, releaseDate: releaseDate, overview: details.firstString(["overview"]) ?? candidate.overview, posterData: posterData, backdropData: backdropData, hasChineseAudio: item.hasChineseAudio, hasChineseSubtitle: item.hasChineseSubtitle, collectionID: collection?.firstInt(["id"]), collectionName: collection?.firstString(["name"])) } private func persistManuallyRecognizedMediaItem(_ updated: MediaLibraryItem, preferredLibraryID: String?) async throws { var targetIDs = Set(mediaLibraryLiveItemsByLibraryID.compactMap { libraryID, values in values.contains(where: { $0.id == updated.id }) ? libraryID : nil }) if let preferredLibraryID { targetIDs.insert(preferredLibraryID) } if targetIDs.isEmpty { for library in mediaLibraries where (await mediaLibraryStore.items(libraryID: library.id)).contains(where: { $0.id == updated.id }) { targetIDs.insert(library.id) } } guard !targetIDs.isEmpty else { throw MediaLibraryStoreError.writeFailed } for libraryID in targetIDs { guard let library = mediaLibraries.first(where: { $0.id == libraryID }), await mediaLibraryStore.upsertItems([updated], library: library) else { throw MediaLibraryStoreError.writeFailed } var liveItems = mediaLibraryLiveItemsByLibraryID[libraryID] ?? [] if let index = liveItems.firstIndex(where: { $0.id == updated.id }) { liveItems[index] = updated } else { liveItems.append(updated) } mediaLibraryLiveItemsByLibraryID[libraryID] = liveItems.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } if isMediaWritebackEnabled { await writeManuallyRecognizedMediaItem(updated, library: library) } } } private func writeManuallyRecognizedMediaItem(_ item: MediaLibraryItem, library: MediaLibraryDefinition) async { let task = MediaLibraryTask(library: library) mediaLibraryTasks.insert(task, at: 0) prepareMediaLibraryTaskResources(task.id) updateMediaLibraryTask(task.id, state: .writing, phase: "正在整理手动识别资源…", currentWriting: item.title, log: "手动识别成功,开始整理:\(item.title)") setMediaLibraryTaskProgress(task.id, completed: 0, total: 1) do { let sidecars = try await mediaSidecarsForWriteback(item) guard await writeMediaScrapeItem(item, files: sidecars, taskID: task.id) else { throw MediaLibraryStoreError.writeFailed } markMediaLibraryFileWritten(task.id, fileID: item.id) updateMediaLibraryTask(task.id, state: .completed, phase: "手动识别整理完成", currentWriting: nil, log: "已完成整理:\(item.title)") } catch { updateMediaLibraryTask(task.id, state: .failed, phase: "手动识别整理失败", currentWriting: nil, failure: error.localizedDescription, log: "整理失败:\(error.localizedDescription)") } } private func mediaSidecarsForWriteback(_ item: MediaLibraryItem) async throws -> MediaSidecarFiles { if item.file.isDirectory { let children = try await allFiles(parentID: item.file.id) if isDiscLayout(children) { return mediaSnapshot(children: children, parentID: item.file.id, parentFolderName: item.file.name, discRoot: item.file).sidecars[item.id] ?? MediaSidecarFiles(parentID: item.file.id, mediaFolderName: item.file.name, nfo: nil, poster: nil, backdrop: nil, subtitles: []) } } let detail = try await api.fsDetail(fileID: item.file.id) let parentID = detail.firstIDDeep(["parentId", "parent_id", "parentFileId"]) let children = try await allFiles(parentID: parentID) return mediaSnapshot(children: children, parentID: parentID, parentFolderName: nil).sidecars[item.id] ?? MediaSidecarFiles(parentID: parentID, mediaFolderName: nil, nfo: nil, poster: nil, backdrop: nil, subtitles: []) } @discardableResult func startMediaLibraryScan(_ library: MediaLibraryDefinition) -> UUID { let task = MediaLibraryTask(library: library) mediaLibraryTasks.insert(task, at: 0) persistMediaLibraryTaskHistory() prepareMediaLibraryTaskResources(task.id) launchMediaLibraryScan(library, taskID: task.id, phase: "正在读取云端目录…", log: "开始扫描云端目录") return task.id } func resumeMediaLibraryTask(_ taskID: UUID) async { guard let task = mediaLibraryTasks.first(where: { $0.id == taskID }), [.cancelled, .failed].contains(task.state), let library = mediaLibraries.first(where: { $0.id == task.libraryID }) else { return } if let scanTask = mediaLibraryScanTasks[taskID] { scanTask.cancel() await scanTask.value } if let writeTask = mediaMetadataWriteTasks[taskID] { writeTask.cancel() await writeTask.value } await seedMediaLibraryTaskCheckpointIfNeeded(taskID, libraryID: library.id) prepareMediaLibraryTaskResources(taskID) resetMediaLibraryTaskForResume(taskID) resumedMediaLibraryTaskIDs.insert(taskID) let checkpoint = mediaLibraryTasks.first { $0.id == taskID }?.checkpoint ?? .init() launchMediaLibraryScan(library, taskID: taskID, phase: "正在继续扫描 \(checkpoint.completedFileIDs.count)/\(checkpoint.discoveredFileIDs.count)…", log: "继续任务:将从 \(checkpoint.completedFileIDs.count) 个已完成文件之后继续") } private func prepareMediaLibraryTaskResources(_ taskID: UUID) { mediaMetadataWriteQueues[taskID] = [] mediaMetadataWrittenKeys[taskID] = [] normalizedMediaFolderIDs[taskID] = [] organizedMediaFolderIDs[taskID] = [] mediaCategoryFolderIDs[taskID] = [:] mediaMetadataWriteFailures[taskID] = 0 } private func resetMediaLibraryTaskForResume(_ taskID: UUID) { guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return } let checkpoint = mediaLibraryTasks[index].checkpoint ?? .init() mediaLibraryTasks[index].state = .queued mediaLibraryTasks[index].progress.completed = checkpoint.completedFileIDs.count mediaLibraryTasks[index].progress.total = checkpoint.discoveredFileIDs.count mediaLibraryTasks[index].progress.phase = "准备继续任务(\(checkpoint.completedFileIDs.count)/\(checkpoint.discoveredFileIDs.count))" mediaLibraryTasks[index].currentScrapingName = nil mediaLibraryTasks[index].currentStoringName = nil mediaLibraryTasks[index].currentWritingName = nil mediaLibraryTasks[index].failureReason = nil mediaLibraryTasks[index].updatedAt = Date() persistMediaLibraryTaskHistory() } private func seedMediaLibraryTaskCheckpointIfNeeded(_ taskID: UUID, libraryID: String) async { guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return } if let checkpoint = mediaLibraryTasks[index].checkpoint, !checkpoint.discoveredFileIDs.isEmpty { return } let cachedIDs = Set((await mediaLibraryStore.items(libraryID: libraryID)).map(\.id)) mediaLibraryTasks[index].checkpoint = MediaLibraryTaskCheckpoint(discoveredFileIDs: cachedIDs, completedFileIDs: cachedIDs) mediaLibraryTasks[index].progress.completed = cachedIDs.count mediaLibraryTasks[index].progress.total = cachedIDs.count persistMediaLibraryTaskHistory() } private func registerMediaLibraryDiscoveredFiles(_ taskID: UUID, files: [CloudFile]) { guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return } var checkpoint = mediaLibraryTasks[index].checkpoint ?? .init() checkpoint.discoveredFileIDs.formUnion(files.map(\.id)) mediaLibraryTasks[index].checkpoint = checkpoint mediaLibraryTasks[index].progress.total = checkpoint.discoveredFileIDs.count mediaLibraryTasks[index].progress.completed = checkpoint.completedFileIDs.count mediaLibraryTasks[index].updatedAt = Date() persistMediaLibraryTaskHistory() } private func markMediaLibraryFileCompleted(_ taskID: UUID, fileID: String) { markMediaLibraryFilesCompleted(taskID, fileIDs: [fileID]) } /// Checkpoints are persisted with the task history. Updating one file at a /// time turns a large scan into thousands of UserDefaults writes, so scraper /// results commit their checkpoints in small batches. private func markMediaLibraryFilesCompleted(_ taskID: UUID, fileIDs: [String]) { guard !fileIDs.isEmpty else { return } guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return } var checkpoint = mediaLibraryTasks[index].checkpoint ?? .init() checkpoint.discoveredFileIDs.formUnion(fileIDs) checkpoint.completedFileIDs.formUnion(fileIDs) mediaLibraryTasks[index].checkpoint = checkpoint mediaLibraryTasks[index].progress.total = checkpoint.discoveredFileIDs.count mediaLibraryTasks[index].progress.completed = checkpoint.completedFileIDs.count mediaLibraryTasks[index].updatedAt = Date() persistMediaLibraryTaskHistory() } private func markMediaLibraryFileWritten(_ taskID: UUID, fileID: String) { guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return } var checkpoint = mediaLibraryTasks[index].checkpoint ?? .init() checkpoint.discoveredFileIDs.insert(fileID) checkpoint.completedFileIDs.insert(fileID) checkpoint.writtenFileIDs.insert(fileID) mediaLibraryTasks[index].checkpoint = checkpoint mediaLibraryTasks[index].updatedAt = Date() persistMediaLibraryTaskHistory() } private func launchMediaLibraryScan(_ library: MediaLibraryDefinition, taskID: UUID, phase: String, log: String) { mediaLibraryScanTasks[taskID] = Task { [weak self] in guard let self else { return } do { _ = try await self.scanMediaLibrary(library, taskID: taskID) self.finishMediaLibraryScan(taskID: taskID) } catch is CancellationError { self.updateMediaLibraryTask(taskID, state: .cancelled, phase: "扫描已停止", log: "用户停止了任务") } catch { self.updateMediaLibraryTask(taskID, state: .failed, phase: "扫描失败", failure: error.localizedDescription, log: "扫描失败:\(error.localizedDescription)") } self.resumedMediaLibraryTaskIDs.remove(taskID) self.mediaLibraryScanTasks[taskID] = nil } updateMediaLibraryTask(taskID, state: .scanning, phase: phase, log: log) } func cancelMediaLibraryTask(_ taskID: UUID) { mediaLibraryScanTasks[taskID]?.cancel() mediaMetadataWriteTasks[taskID]?.cancel() mediaMetadataWriteQueues[taskID] = [] updateMediaLibraryTask(taskID, state: .cancelled, phase: "任务已停止", log: "用户停止了任务") } func removeMediaLibraryTask(_ taskID: UUID) { guard let task = mediaLibraryTasks.first(where: { $0.id == taskID }), !task.isActive else { return } mediaLibraryTasks.removeAll { $0.id == taskID } mediaLibraryScanTasks[taskID] = nil mediaMetadataWriteTasks[taskID] = nil mediaMetadataWriteQueues[taskID] = nil mediaMetadataWrittenKeys[taskID] = nil normalizedMediaFolderIDs[taskID] = nil organizedMediaFolderIDs[taskID] = nil mediaCategoryFolderIDs[taskID] = nil mediaMetadataWriteFailures[taskID] = nil persistMediaLibraryTaskHistory() } func clearFinishedMediaLibraryTasks() { let finishedIDs = mediaLibraryTasks.filter { !$0.isActive }.map(\.id) for taskID in finishedIDs { removeMediaLibraryTask(taskID) } } private func updateMediaLibraryTask(_ taskID: UUID, state: MediaLibraryTaskState? = nil, phase: String? = nil, currentScraping: String? = nil, currentStoring: String? = nil, currentWriting: String? = nil, failure: String? = nil, log: String? = nil) { guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return } if let state { mediaLibraryTasks[index].state = state } if let phase { mediaLibraryTasks[index].progress.phase = phase } if let currentScraping { mediaLibraryTasks[index].currentScrapingName = currentScraping } if let currentStoring { mediaLibraryTasks[index].currentStoringName = currentStoring } if let currentWriting { mediaLibraryTasks[index].currentWritingName = currentWriting } if let failure { mediaLibraryTasks[index].failureReason = failure } if let log { mediaLibraryTasks[index].logs.append(MediaLibraryTaskLog(message: log)) } if let state, [.completed, .cancelled, .failed].contains(state) { mediaLibraryTasks[index].currentScrapingName = nil mediaLibraryTasks[index].currentStoringName = nil mediaLibraryTasks[index].currentWritingName = nil } mediaLibraryTasks[index].updatedAt = Date() persistMediaLibraryTaskHistory() } private func setMediaLibraryTaskProgress(_ taskID: UUID, completed: Int? = nil, total: Int? = nil, phase: String? = nil) { guard let index = mediaLibraryTasks.firstIndex(where: { $0.id == taskID }) else { return } if let completed { mediaLibraryTasks[index].progress.completed = completed } if let total { mediaLibraryTasks[index].progress.total = total } if let phase { mediaLibraryTasks[index].progress.phase = phase } mediaLibraryTasks[index].updatedAt = Date() persistMediaLibraryTaskHistory() } private func persistMediaLibraryTaskHistory() { let history = Array(mediaLibraryTasks.prefix(100)) guard let data = try? JSONEncoder().encode(history) else { return } UserDefaults.standard.set(data, forKey: "guangya.mediaLibraryTaskHistory") } private func scanMediaLibrary(_ library: MediaLibraryDefinition, taskID: UUID) async throws -> [MediaLibraryItem] { await mediaLibraryStore.save(library: library) let existingItems = await mediaLibraryStore.items(libraryID: library.id) let existingItemsByFileID = Dictionary(uniqueKeysWithValues: existingItems.map { ($0.id, $0) }) mediaLibraryLiveItemsByLibraryID[library.id] = existingItems var itemsByFileID = existingItemsByFileID var allSidecars: [String: MediaSidecarFiles] = [:] var seenFingerprints = Set() updateMediaLibraryTask(taskID, phase: "正在发现媒体…", log: "正在发现媒体文件") if tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { updateMediaLibraryTask(taskID, phase: "TMDB 未配置,无法识别", failure: "请在影视模式侧栏配置 TMDB API Key", log: "TMDB API Key 未配置,文件将以未识别状态入库") } for try await discovered in mediaDirectorySnapshots(for: library) { try Task.checkCancellation() allSidecars.merge(discovered.sidecars) { current, _ in current } let enriched = await enrichMediaFingerprints(discovered.files) let newFiles = enriched.filter { file in let key = file.gcid.flatMap { $0.isEmpty ? nil : "gcid:\($0.lowercased())" } ?? "file:\(file.id)" return seenFingerprints.insert(key).inserted } registerMediaLibraryDiscoveredFiles(taskID, files: newFiles) let completedIDs = mediaLibraryTasks.first { $0.id == taskID }?.checkpoint?.completedFileIDs ?? [] let writtenIDs = mediaLibraryTasks.first { $0.id == taskID }?.checkpoint?.writtenFileIDs ?? [] let isResuming = resumedMediaLibraryTaskIDs.contains(taskID) let pendingFiles = newFiles.filter { !completedIDs.contains($0.id) || (isResuming && !writtenIDs.contains($0.id)) } guard !pendingFiles.isEmpty else { continue } let checkpoint = mediaLibraryTasks.first { $0.id == taskID }?.checkpoint ?? .init() setMediaLibraryTaskProgress(taskID, completed: checkpoint.completedFileIDs.count, total: checkpoint.discoveredFileIDs.count, phase: "\(library.kind.title)模式 · 正在使用 TMDB 刮削…") let batchItems = try await scrapeMediaFiles(pendingFiles, library: library, sidecars: allSidecars, existingItemsByFileID: existingItemsByFileID, taskID: taskID, retryingWritebackFor: writtenIDs) for item in batchItems { itemsByFileID[item.id] = item } } let discoveredIDs = mediaLibraryTasks.first { $0.id == taskID }?.checkpoint?.discoveredFileIDs ?? Set(itemsByFileID.keys) var items = itemsByFileID.values.filter { discoveredIDs.contains($0.id) } items.sort { $0.title.localizedStandardCompare($1.title) == .orderedAscending } var updatedLibrary = library updatedLibrary.updatedAt = Date() await mediaLibraryStore.save(library: updatedLibrary) updateMediaLibraryTask(taskID, phase: "正在入库…", currentStoring: "\(items.count) 个媒体条目", log: "正在写入本机影视库") guard await mediaLibraryStore.replaceItems(items, library: updatedLibrary) else { throw MediaLibraryStoreError.writeFailed } await loadMediaLibraries() mediaLibraryLiveItemsByLibraryID[library.id] = items updateMediaLibraryTask(taskID, phase: "刮削入库完成", currentStoring: nil, log: "已入库 \(items.count) 个媒体条目") return items } private func finishMediaLibraryScan(taskID: UUID) { if !isMediaWritebackEnabled { updateMediaLibraryTask(taskID, state: .completed, phase: "任务完成", currentScraping: nil, currentStoring: nil, log: "已完成本机刮削入库;云端回写已关闭") return } guard let queuedWrites = mediaMetadataWriteQueues[taskID], !queuedWrites.isEmpty else { updateMediaLibraryTask(taskID, state: .completed, phase: "任务完成", currentScraping: nil, currentStoring: nil, log: "没有需要写回的资源,任务完成") return } updateMediaLibraryTask(taskID, state: .writing, phase: "等待写回资源目录", currentScraping: nil, currentStoring: nil, log: "开始回写 \(queuedWrites.count) 个资源目录") startMediaMetadataWriteback(taskID: taskID) } private func enrichMediaFingerprints(_ files: [CloudFile]) async -> [CloudFile] { let api = self.api let metadataCache = self.metadataCache let concurrency = mediaScrapeConcurrency var iterator = files.makeIterator() return await withTaskGroup(of: CloudFile.self, returning: [CloudFile].self) { group in func addNext() { guard let file = iterator.next() else { return } group.addTask { guard file.gcid?.isEmpty != false, 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"]) ?? file.size await metadataCache.save(fileID: file.id, isDirectory: false, detail: detail) return copy } } for _ in 0.. AsyncThrowingStream { AsyncThrowingStream { (continuation: AsyncThrowingStream.Continuation) in let producer = Task { @MainActor [weak self] in guard let self else { continuation.finish(); return } do { var queue: [(id: String?, path: [String], folder: CloudFile?)] = library.sources.map { source in let path = source.rootID == nil ? [] : source.path.split(separator: "/").map(String.init) let root = source.rootID.map { id in CloudFile(id: id, name: path.last ?? source.path, isDirectory: true, size: 0, gcid: nil, subDirectoryCount: nil, subFileCount: nil, modifiedAt: "", cloudPath: "/" + path.joined(separator: "/"), fileType: 0) } return (source.rootID, path, root) } while !queue.isEmpty { try Task.checkCancellation() let current = queue.removeFirst() // A scan can be rooted at BDMV/VIDEO_TS itself. In that // case there is no parent disc folder for isDiscLayout to // detect, so block the internal transport streams here. if Self.isDiscInternalPath(current.path) { continue } var children = try await self.allFiles(parentID: current.id) for index in children.indices { children[index].cloudPath = "/" + (current.path + [children[index].name]).joined(separator: "/") } // A Blu-ray/DVD directory is one playable work. Do not walk into // BDMV/STREAM or VIDEO_TS, otherwise each transport stream is // incorrectly scraped as a separate film. if let discRoot = current.folder, self.isDiscLayout(children) { let snapshot = self.mediaSnapshot(children: children, parentID: current.id, parentFolderName: discRoot.name, discRoot: discRoot, minimumSizeBytes: Int64(library.minimumSizeMB) * 1_024 * 1_024) if !snapshot.files.isEmpty { continuation.yield(snapshot) } continue } let snapshot = self.mediaSnapshot(children: children, parentID: current.id, parentFolderName: current.folder?.name, minimumSizeBytes: Int64(library.minimumSizeMB) * 1_024 * 1_024) if !snapshot.files.isEmpty { continuation.yield(snapshot) } if library.recursive { queue += children.filter(\.isDirectory).map { ($0.id, current.path + [$0.name], $0) } } } continuation.finish() } catch { continuation.finish(throwing: error) } } continuation.onTermination = { @Sendable _ in producer.cancel() } } } private func isDiscLayout(_ children: [CloudFile]) -> Bool { let names = Set(children.filter(\.isDirectory).map { $0.name.uppercased() }) return names.contains("BDMV") || names.contains("VIDEO_TS") || (names.contains("CERTIFICATE") && names.contains("BDMV")) } private nonisolated static func isDiscInternalPath(_ path: [String]) -> Bool { let components = Set(path.map { $0.uppercased() }) return components.contains("BDMV") || components.contains("VIDEO_TS") } private func mediaSnapshot(children: [CloudFile], parentID: String?, parentFolderName: String?, discRoot: CloudFile? = nil, minimumSizeBytes: Int64 = 0) -> MediaScanSnapshot { var directMedia = children.filter { $0.isVideo && !Self.isRawDiscTransportStream($0) && ($0.size ?? 0) >= minimumSizeBytes } if let discRoot { directMedia = [discRoot] } let ordinaryFiles = children.filter { !$0.isDirectory && !$0.isVideo } var sidecars: [String: MediaSidecarFiles] = [:] for video in directMedia { let stem = (video.name as NSString).deletingPathExtension.lowercased() func first(named names: Set, extensions: Set) -> CloudFile? { ordinaryFiles.first { file in let lower = file.name.lowercased() let ext = (lower as NSString).pathExtension return names.contains(lower) || (extensions.contains(ext) && (lower.hasPrefix(stem + ".") || lower.hasPrefix(stem + "-") || lower.hasPrefix(stem + "_"))) } } let nfoFiles = ordinaryFiles.filter { ($0.name as NSString).pathExtension.lowercased() == "nfo" } let nfo = first(named: ["movie.nfo", "tvshow.nfo"], extensions: ["nfo"]) ?? (nfoFiles.count == 1 ? nfoFiles.first : nil) let poster = first(named: ["poster.jpg", "poster.jpeg", "poster.png", "folder.jpg", "folder.png"], extensions: ["jpg", "jpeg", "png", "webp"]) let backdrop = first(named: ["fanart.jpg", "fanart.png", "backdrop.jpg", "background.jpg"], extensions: ["jpg", "jpeg", "png", "webp"]) let subtitleExtensions: Set = ["srt", "ass", "ssa", "sub", "vtt", "sup"] let subtitles = ordinaryFiles.filter { file in let lower = file.name.lowercased() guard subtitleExtensions.contains((lower as NSString).pathExtension) else { return false } let subtitleStem = (lower as NSString).deletingPathExtension return subtitleStem == stem || subtitleStem.hasPrefix(stem + ".") || subtitleStem.hasPrefix(stem + "-") || subtitleStem.hasPrefix(stem + "_") } // Disc artwork belongs beside BDMV/VIDEO_TS at the disc root, never // inside its internal structure. sidecars[video.id] = MediaSidecarFiles(parentID: discRoot == nil ? parentID : discRoot?.id, mediaFolderName: discRoot?.name ?? parentFolderName, nfo: nfo, poster: poster, backdrop: backdrop, subtitles: subtitles) } return MediaScanSnapshot(files: directMedia, sidecars: sidecars) } private nonisolated static func isRawDiscTransportStream(_ file: CloudFile) -> Bool { let name = file.name.trimmingCharacters(in: .whitespacesAndNewlines) let extensionName = (name as NSString).pathExtension.lowercased() let stem = (name as NSString).deletingPathExtension let numericStream = stem.range(of: "^0*\\d{3,6}$", options: .regularExpression) != nil // Disc folders commonly contain zero-padded transport stream names such // as 00000.mkv / 00001.mp4 after being repackaged. They are never a // standalone title, irrespective of the extension reported by the API. let zeroPaddedStream = stem.range(of: "^0{2,}\\d+$", options: .regularExpression) != nil let dvdStream = stem.range(of: "(?i)^VTS_\\d{1,2}_\\d{1,2}$", options: .regularExpression) != nil if zeroPaddedStream { return true } if ["m2ts", "mts", "vob"].contains(extensionName) { return numericStream || dvdStream } // Some cloud API responses omit the extension of a recognised video. return extensionName.isEmpty && file.fileType == 2 && numericStream } private func scrapeMediaFiles(_ files: [CloudFile], library: MediaLibraryDefinition, sidecars: [String: MediaSidecarFiles], existingItemsByFileID: [String: MediaLibraryItem], taskID: UUID, retryingWritebackFor writtenFileIDs: Set) async throws -> [MediaLibraryItem] { let kind = library.kind let requests = files.enumerated().map { index, file in (index: index, file: file, parsed: mediaParse(fileName: file.name, folderName: sidecars[file.id]?.mediaFolderName)) } let api = self.api let apiKey = tmdbAPIKey let proxyHost = tmdbProxyHost let proxyPort = tmdbProxyPort let scrapeConcurrency = mediaScrapeConcurrency let candidateCache = MediaTMDBCandidateCache() var iterator = requests.makeIterator() let results = await withTaskGroup(of: MediaScrapeResult.self, returning: [MediaScrapeResult].self) { group in func addNext() { guard let request = iterator.next() else { return } group.addTask { if let existing = existingItemsByFileID[request.file.id], existing.isMatched { // A subsequent scan is incremental: recognized media keeps // its stored scrape result and skips both TMDB and writeback. let current = MediaLibraryItem(file: request.file, tmdbID: existing.tmdbID, title: existing.title, originalTitle: existing.originalTitle, mediaKind: existing.mediaKind, releaseDate: existing.releaseDate, overview: existing.overview, posterData: existing.posterData, backdropData: existing.backdropData, hasChineseAudio: existing.hasChineseAudio, hasChineseSubtitle: existing.hasChineseSubtitle, collectionID: existing.collectionID, collectionName: existing.collectionName) let needsWriteback = !writtenFileIDs.contains(request.file.id) let note = needsWriteback ? "已入库,继续未完成的目录写回" : "已入库,跳过重新识别" return MediaScrapeResult(index: request.index, item: current, note: note, reusedExisting: true, needsWriteback: needsWriteback) } let language = Self.mediaLanguageProfile(fileName: request.file.name, subtitles: sidecars[request.file.id]?.subtitles ?? []) let fallback = MediaLibraryItem(file: request.file, tmdbID: nil, title: request.parsed.title, originalTitle: request.file.name, mediaKind: nil, releaseDate: request.parsed.year.map(String.init) ?? "", overview: "", posterData: nil, backdropData: nil, hasChineseAudio: language.audio, hasChineseSubtitle: language.subtitle, collectionID: nil, collectionName: nil) do { if let existing = await Self.existingScrape(for: request.file, fallback: fallback, sidecars: sidecars[request.file.id], fallbackKind: kind, api: api) { return MediaScrapeResult(index: request.index, item: existing, note: nil, reusedExisting: false) } guard !apiKey.isEmpty else { return MediaScrapeResult(index: request.index, item: fallback, note: "TMDB API Key 未配置", reusedExisting: false) } // Library type controls only destination categories. The // scraper itself searches TMDB automatically so misplaced // films/series can still be identified correctly. let preferredKind: TMDBMediaKind = request.parsed.isEpisode ? .tv : .automatic let candidate: TMDBCandidate let details: JSONValue? if let embeddedID = Self.embeddedTMDBID(in: request.file.cloudPath) { let initialKind: TMDBMediaKind = preferredKind == .automatic ? (request.parsed.isEpisode ? .tv : .movie) : preferredKind var embeddedDetails = try? await api.tmdbDetails(id: embeddedID, mediaKind: initialKind, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort) var resolvedKind = initialKind if embeddedDetails == nil, preferredKind == .automatic { resolvedKind = initialKind == .movie ? .tv : .movie embeddedDetails = try? await api.tmdbDetails(id: embeddedID, mediaKind: resolvedKind, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort) } guard let embeddedDetails, let embeddedCandidate = TMDBCandidate(json: embeddedDetails, forcedKind: resolvedKind) else { return MediaScrapeResult(index: request.index, item: fallback, note: "TMDB ID 无效或媒体类型不匹配", reusedExisting: false) } candidate = embeddedCandidate details = embeddedDetails } else { let cacheKey = "\(preferredKind.rawValue):\(request.parsed.year.map(String.init) ?? ""):\(Self.normalizedTMDBLookupTitle(request.parsed.title))" let searched: TMDBCandidate? if let cached = await candidateCache.candidate(for: cacheKey) { searched = cached } else { let resolved = try await Self.mediaTMDBCandidate(for: request.parsed, preferredKind: preferredKind, api: api, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort) if let resolved { await candidateCache.store(resolved, for: cacheKey) } searched = resolved } guard let searched else { return MediaScrapeResult(index: request.index, item: fallback, note: "TMDB 未找到匹配候选:\(request.parsed.title)", reusedExisting: false) } candidate = searched details = try? await api.tmdbDetails(id: searched.id, mediaKind: searched.mediaType, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort) } let localizedTitle = details?.firstString(["title", "name"]) ?? candidate.title let originalTitle = details?.firstString(["original_title", "original_name"]) ?? candidate.originalTitle let localizedOverview = details?.firstString(["overview"]) ?? candidate.overview let localizedReleaseDate = details?.firstString(["release_date", "first_air_date"]) ?? candidate.releaseDate let localizedPosterPath = details.flatMap { Self.preferredArtworkPath(in: $0, collection: "posters") } ?? details?.firstString(["poster_path"]) ?? candidate.posterPath let localizedBackdropPath = details.flatMap { Self.preferredArtworkPath(in: $0, collection: "backdrops") } ?? details?.firstString(["backdrop_path"]) ?? candidate.backdropPath let collection = details?["belongs_to_collection"] let collectionID = collection?.firstInt(["id"]) let collectionName = collection?.firstString(["name"]) let posterData: Data? if let path = localizedPosterPath { posterData = try? await api.tmdbImage(path: path, size: "w500", apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort) } else { posterData = nil } let backdropData: Data? if request.index < 8, let path = localizedBackdropPath { backdropData = try? await api.tmdbImage(path: path, size: "w1280", apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort) } else { backdropData = nil } let item = MediaLibraryItem(file: request.file, tmdbID: candidate.id, title: localizedTitle, originalTitle: originalTitle, mediaKind: candidate.mediaType, releaseDate: localizedReleaseDate, overview: localizedOverview, posterData: posterData, backdropData: backdropData, hasChineseAudio: language.audio, hasChineseSubtitle: language.subtitle, collectionID: collectionID, collectionName: collectionName) return MediaScrapeResult(index: request.index, item: item, note: nil, reusedExisting: false) } catch { return MediaScrapeResult(index: request.index, item: fallback, note: "TMDB 请求失败:\(error.localizedDescription)", reusedExisting: false) } } } for _ in 0..= 8 { await flushPendingStorage() } addNext() } await flushPendingStorage() return values } try Task.checkCancellation() return results.sorted { $0.index < $1.index }.map(\.item) } /// Commit a small completed scrape batch on the main actor. Keeping this /// outside the task-group closure lets TMDB work stay concurrent while UI /// state and the SQLite checkpoint are updated safely in larger chunks. private func commitScrapedMediaBatch(_ batch: [MediaScrapeResult], library: MediaLibraryDefinition, sidecars: [String: MediaSidecarFiles], taskID: UUID) async -> Bool { let batchItems = batch.map(\.item) guard await mediaLibraryStore.upsertItems(batchItems, library: library) else { return false } markMediaLibraryFilesCompleted(taskID, fileIDs: batchItems.map(\.id)) let currentName = batchItems.last.map { $0.title.isEmpty ? $0.file.name : $0.title } ?? "" let logs = batch.map { value -> String in let name = value.item.title.isEmpty ? value.item.file.name : value.item.title if value.reusedExisting { return "已入库,跳过重新识别:\(name)" } return value.item.isMatched ? "TMDB 已识别:\(name)" : "TMDB 未识别:\(value.item.file.name)(\(value.note ?? "未返回匹配结果"))" } updateMediaLibraryTask(taskID, currentScraping: currentName, currentStoring: currentName, log: logs.joined(separator: "\n")) // Publish the first result immediately, then publish compact batches. // This lets the poster wall appear progressively while avoiding a full // sort and SwiftUI grid refresh for every individual media file. var liveItems = mediaLibraryLiveItemsByLibraryID[library.id] ?? [] var liveIndexes = Dictionary(uniqueKeysWithValues: liveItems.enumerated().map { ($0.element.id, $0.offset) }) for item in batchItems { if let index = liveIndexes[item.id] { liveItems[index] = item } else { liveIndexes[item.id] = liveItems.count liveItems.append(item) } } mediaLibraryLiveItemsByLibraryID[library.id] = liveItems if isMediaWritebackEnabled { for value in batch where (!value.reusedExisting || value.needsWriteback) { if let files = sidecars[value.item.id] { enqueueMediaMetadataWriteback(value.item, files: files, taskID: taskID) } } } return true } private nonisolated static func mediaTMDBCandidate(for parsed: ParsedMediaName, preferredKind: TMDBMediaKind, api: GuangyaAPI, apiKey: String, proxyHost: String, proxyPort: String) async throws -> TMDBCandidate? { let queries = mediaTMDBQueries(parsed.title) var tried = Set() let kinds: [TMDBMediaKind] switch preferredKind { case .automatic: kinds = [.automatic, .movie, .tv] case .movie: kinds = [.movie, .tv] case .tv: kinds = [.tv, .movie] } for query in queries where tried.insert(query.lowercased()).inserted { for kind in kinds { let response = try await api.tmdbSearch(query: query, apiKey: apiKey, mediaKind: kind, proxyHost: proxyHost, proxyPort: proxyPort) let candidates = (response["results"]?.arrayValue ?? []).compactMap { TMDBCandidate(json: $0, forcedKind: kind == .automatic ? nil : kind) } if let candidate = candidates .map({ ($0, mediaTMDBMatchScore($0, query: query, expectedYear: parsed.year, isEpisode: parsed.isEpisode)) }) .filter({ $0.1 >= 0.42 }) .max(by: { $0.1 < $1.1 })?.0 { return candidate } } } return nil } private nonisolated static func normalizedTMDBLookupTitle(_ value: String) -> String { value.lowercased() .folding(options: [.diacriticInsensitive, .widthInsensitive], locale: .current) .replacingOccurrences(of: "[^\\p{Han}a-z0-9]", with: "", options: .regularExpression) } private nonisolated static func mediaTMDBMatchScore(_ candidate: TMDBCandidate, query rawQuery: String, expectedYear: Int?, isEpisode: Bool) -> Double { let query = normalizedTMDBLookupTitle(rawQuery) guard query.count >= 2 else { return 0 } let candidateTitles = [candidate.title, candidate.originalTitle].map(normalizedTMDBLookupTitle).filter { !$0.isEmpty } var score = candidateTitles.map { title -> Double in if title == query { return 1.0 } if title.contains(query) || query.contains(title) { return Double(min(title.count, query.count)) / Double(max(title.count, query.count)) * 0.9 } let shared = Set(title).intersection(Set(query)).count return Double(shared) / Double(max(title.count, query.count)) }.max() ?? 0 if let year = expectedYear { let candidateYear = Int(candidate.releaseDate.prefix(4)) if candidateYear == year { score += 0.22 } else if let candidateYear, abs(candidateYear - year) > 1 { score -= 0.18 } } if isEpisode && candidate.mediaType == .tv { score += 0.12 } return score } private nonisolated static func mediaTMDBQueries(_ title: String) -> [String] { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) let withoutBracketMetadata = trimmed .replacingOccurrences(of: "【[^】]*】|\\[[^\\]]*\\]|([^)]*)|\\([^)]*\\)|\\{[^}]*\\}", with: " ", options: .regularExpression) let cleaned = withoutBracketMetadata .replacingOccurrences(of: "(?i)\\b(remastered|remux|proper|repack|extended|unrated|imax|criterion|complete|limited|web[- ]?dl|webrip|bluray|bdrip|uhd|dv|hdr10?\\+?|avc|vc-1|truehd|atmos|dts(?:-hd)?|ddp?(?: ?[0-9.]+)?|aac|flac|60fps|50fps|10bit|8bit|x26[45]|h\\.?26[45]|hevc|av1)\\b", with: " ", options: .regularExpression) .replacingOccurrences(of: "[-_.]([A-Za-z0-9]{3,})$", with: "", options: .regularExpression) .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) let withoutSeason = cleaned.replacingOccurrences(of: "(?i)(?:season|series|s)\\s*0?\\d{1,2}\\b", with: " ", options: .regularExpression) .replacingOccurrences(of: "第\\s*\\d{1,2}\\s*季|全\\s*\\d{1,3}\\s*集|(?:国语|普通话|粤语)(?:配音)?|(?:简体|繁体|中英|双语|中文)字幕|已?完结|更新至\\s*\\d{1,3}\\s*集", with: " ", options: .regularExpression) .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) let compactLatin = withoutSeason.range(of: "[\\p{Han}]", options: .regularExpression) == nil ? withoutSeason.replacingOccurrences(of: "[^A-Za-z0-9]", with: "", options: .regularExpression) : "" let chineseAlias = withoutSeason .replacingOccurrences(of: "(?:\\d{1,3}\\s*帧(?:率)?(?:版本)?|全\\s*\\d{1,3}\\s*集|更新至\\s*\\d{1,3}\\s*集|(?:国语|普通话|粤语)(?:配音)?|(?:简体|繁体|中英|双语|中文)字幕|已?完结)", with: " ", options: .regularExpression) .replacingOccurrences(of: "[^\\p{Han}]", with: "", options: .regularExpression) let latinAlias = withoutSeason.replacingOccurrences(of: "[\\p{Han}]+", with: " ", options: .regularExpression) .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) let compactLatinAlias = latinAlias.replacingOccurrences(of: "[^A-Za-z0-9]", with: "", options: .regularExpression) // Releases commonly place the Chinese and international titles together. // TMDB indexes either alias, but often not their concatenated form. // Prefer a clean Chinese alias, then the international alias. A combined // Chinese/English release string is useful only as a final fallback. var seen = Set() return [chineseAlias, latinAlias, compactLatinAlias, withoutSeason, cleaned, trimmed, compactLatin] .filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted } } private nonisolated static func existingScrape(for file: CloudFile, fallback: MediaLibraryItem, sidecars: MediaSidecarFiles?, fallbackKind: MediaLibraryKind, api: GuangyaAPI) async -> MediaLibraryItem? { guard let sidecars else { return nil } async let nfoData = sidecarData(for: sidecars.nfo, api: api) async let posterData = sidecarData(for: sidecars.poster, api: api) async let backdropData = sidecarData(for: sidecars.backdrop, api: api) let (loadedNFO, loadedPoster, loadedBackdrop) = await (nfoData, posterData, backdropData) guard let parsed = loadedNFO.flatMap(MediaNFOParser.parse) else { guard loadedPoster != nil || loadedBackdrop != nil else { return nil } return MediaLibraryItem(file: file, tmdbID: fallback.tmdbID, title: fallback.title, originalTitle: fallback.originalTitle, mediaKind: fallback.mediaKind, releaseDate: fallback.releaseDate, overview: fallback.overview, posterData: loadedPoster, backdropData: loadedBackdrop, hasChineseAudio: fallback.hasChineseAudio, hasChineseSubtitle: fallback.hasChineseSubtitle, collectionID: fallback.collectionID, collectionName: fallback.collectionName) } let inferredKind: TMDBMediaKind? switch fallbackKind { case .movies: inferredKind = .movie case .series: inferredKind = .tv case .mixed: inferredKind = parsed.mediaKind } return MediaLibraryItem(file: file, tmdbID: parsed.tmdbID, title: parsed.title, originalTitle: parsed.originalTitle, mediaKind: parsed.mediaKind ?? inferredKind, releaseDate: parsed.releaseDate, overview: parsed.overview, posterData: loadedPoster, backdropData: loadedBackdrop, hasChineseAudio: parsed.hasChineseAudio || fallback.hasChineseAudio, hasChineseSubtitle: parsed.hasChineseSubtitle || fallback.hasChineseSubtitle, collectionID: parsed.collectionID, collectionName: parsed.collectionName) } private nonisolated static func mediaLanguageProfile(fileName: String, subtitles: [CloudFile]) -> (audio: Bool, subtitle: Bool) { let lower = fileName.lowercased() let audioMarkers = ["国语", "普通话", "国粤", "粤语", "mandarin", "cantonese", "chinese.audio", "chi.audio", "zh.audio"] let subtitleMarkers = ["中文", "中字", "简中", "繁中", "双语", ".chs", ".cht", ".chi", ".zho", ".zh", "chinese", "bilingual"] let hasAudio = audioMarkers.contains { lower.contains($0) } let hasSubtitle = subtitleMarkers.contains { lower.contains($0) } || subtitles.contains { subtitle in let name = subtitle.name.lowercased() return subtitleMarkers.contains { name.contains($0) } } return (hasAudio, hasSubtitle) } private nonisolated static func embeddedTMDBID(in cloudPath: String) -> Int? { let folderPath = (cloudPath as NSString).deletingLastPathComponent guard let regex = try? NSRegularExpression(pattern: "(?i)tmdb(?:id)?[\\s._:=\\-\\[\\(]*(\\d{2,})"), let match = regex.matches(in: folderPath, range: NSRange(folderPath.startIndex..., in: folderPath)).last, let range = Range(match.range(at: 1), in: folderPath) else { return nil } return Int(folderPath[range]) } private nonisolated static func preferredArtworkPath(in details: JSONValue, collection: String) -> String? { let images = details["images"]?[collection]?.arrayValue ?? [] let languageOrder: [String?] = ["zh", "zh-CN", nil, "en"] for language in languageOrder { if let path = images.first(where: { image in let code = image.firstString(["iso_639_1"]) return language == nil ? code == nil : code?.caseInsensitiveCompare(language!) == .orderedSame })?.firstString(["file_path"]) { return path } } return images.first?.firstString(["file_path"]) } private nonisolated static func sidecarData(for file: CloudFile, api: GuangyaAPI) async -> Data? { guard let result = try? await api.downloadURL(fileID: file.id), let value = result.firstStringDeep(["url", "downloadUrl", "download_url", "dlink"]), let url = URL(string: value) else { return nil } return try? await URLSession.shared.data(from: url).0 } private nonisolated static func sidecarData(for file: CloudFile?, api: GuangyaAPI) async -> Data? { guard let file else { return nil } return await sidecarData(for: file, api: api) } private func enqueueMediaMetadataWriteback(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID, organizeOnly: Bool = false) { guard item.isMatched else { return } mediaMetadataWriteQueues[taskID, default: []].append(MediaMetadataWriteEntry(item: item, files: files, organizeOnly: organizeOnly)) } private func startMediaMetadataWriteback(taskID: UUID) { guard mediaMetadataWriteTasks[taskID] == nil else { return } mediaMetadataWriteTasks[taskID] = Task { [weak self] in guard let self else { return } let total = self.mediaMetadataWriteQueues[taskID]?.count ?? 0 var completed = 0 while !(self.mediaMetadataWriteQueues[taskID]?.isEmpty ?? true), !Task.isCancelled { guard let entry = self.mediaMetadataWriteQueues[taskID]?.removeFirst() else { break } let item = entry.item let name = item.title.isEmpty ? item.file.name : item.title let phase = entry.organizeOnly ? "正在分类整理资源目录…" : "正在回写资源目录…" self.updateMediaLibraryTask(taskID, state: .writing, phase: phase, currentWriting: name, log: "正在\(entry.organizeOnly ? "分类整理" : "回写"):\(name)") if await self.writeMediaScrapeItem(item, files: entry.files, taskID: taskID, organizeOnly: entry.organizeOnly) { self.markMediaLibraryFileWritten(taskID, fileID: item.id) } else { self.mediaMetadataWriteFailures[taskID, default: 0] += 1 } completed += 1 self.setMediaLibraryTaskProgress(taskID, phase: "资源目录写回 \(completed)/\(total)") } self.mediaMetadataWriteTasks[taskID] = nil guard !Task.isCancelled else { return } let failures = self.mediaMetadataWriteFailures[taskID, default: 0] self.updateMediaLibraryTask(taskID, state: failures == 0 ? .completed : .failed, phase: failures == 0 ? "资源目录写回完成" : "写回失败,\(failures) 项待续跑", currentWriting: "", failure: failures == 0 ? nil : "\(failures) 项资源目录未完成写回", log: failures == 0 ? "任务完成" : "任务未完全写回,可继续任务") } } private func writeMediaScrapeItem(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID, organizeOnly: Bool = false) async -> Bool { do { let libraryID = mediaLibraryTasks.first(where: { $0.id == taskID })?.libraryID let library = libraryID.flatMap { id in mediaLibraries.first { $0.id == id } } let resolvedFiles: MediaSidecarFiles if let library, let workFolderID = try await resolvedMediaWorkFolderID(for: item, files: files, library: library) { resolvedFiles = MediaSidecarFiles(parentID: workFolderID, mediaFolderName: files.mediaFolderName, nfo: files.nfo, poster: files.poster, backdrop: files.backdrop, subtitles: files.subtitles) } else { resolvedFiles = files } let preparedFiles = try await prepareMediaWorkFolder(item, files: resolvedFiles, taskID: taskID) let parentKey = preparedFiles.parentID ?? "root" await organizeMediaLibraryFolder(item, files: preparedFiles, taskID: taskID) if organizeOnly { return true } await normalizeMediaLibraryFolder(item, files: preparedFiles, taskID: taskID) await normalizeEpisodeFileName(item, files: preparedFiles, taskID: taskID) if preparedFiles.nfo == nil { let name = item.mediaKind == .tv ? "tvshow.nfo" : "movie.nfo" let key = "\(parentKey)/\(name.lowercased())" if mediaMetadataWrittenKeys[taskID, default: []].insert(key).inserted { try await uploadScrapeData(Data(mediaNFO(for: item).utf8), name: name, contentType: "application/xml", parentID: preparedFiles.parentID) } } if preparedFiles.poster == nil, let data = item.posterData { let key = "\(parentKey)/poster.jpg" if mediaMetadataWrittenKeys[taskID, default: []].insert(key).inserted { try await uploadScrapeData(data, name: "poster.jpg", contentType: "image/jpeg", parentID: preparedFiles.parentID) } } if preparedFiles.backdrop == nil, let data = item.backdropData { let key = "\(parentKey)/fanart.jpg" if mediaMetadataWrittenKeys[taskID, default: []].insert(key).inserted { try await uploadScrapeData(data, name: "fanart.jpg", contentType: "image/jpeg", parentID: preparedFiles.parentID) } } return true } catch { return false } } private func prepareMediaWorkFolder(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID) async throws -> MediaSidecarFiles { guard !item.file.isDirectory, let parentID = files.parentID, let libraryID = mediaLibraryTasks.first(where: { $0.id == taskID })?.libraryID, let library = mediaLibraries.first(where: { $0.id == libraryID }), library.sources.compactMap(\.rootID).contains(parentID), let tmdbID = item.tmdbID else { return files } let folderName = mediaWorkFolderName(item, tmdbID: tmdbID) let folderID = try await mediaCategoryFolderID(named: folderName, parentID: parentID, taskID: taskID) let attachments = [files.nfo, files.poster, files.backdrop].compactMap { $0 } + files.subtitles let ids = Array(Set(([item.file] + attachments).map(\.id))) _ = try await api.fsMove(fileIDs: ids, parentID: folderID) updateMediaLibraryTask(taskID, currentWriting: folderName, log: "已为散文件创建作品目录:\(folderName)") return MediaSidecarFiles(parentID: folderID, mediaFolderName: folderName, nfo: files.nfo, poster: files.poster, backdrop: files.backdrop, subtitles: files.subtitles) } private func normalizeEpisodeFileName(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID) async { guard !item.file.isDirectory, item.mediaKind == .tv, item.tmdbID != nil, let libraryID = mediaLibraryTasks.first(where: { $0.id == taskID })?.libraryID, let library = mediaLibraries.first(where: { $0.id == libraryID }), let workFolderID = try? await resolvedMediaWorkFolderID(for: item, files: files, library: library) else { return } let parsed = parseMediaName(item.file.name) guard parsed.isEpisode, let season = parsed.season, let episode = parsed.episode else { return } let stem = (item.file.name as NSString).deletingPathExtension let hasStandardEpisode = stem.range(of: "(?i)\\bS\\d{2}E\\d{2,3}\\b", options: .regularExpression) != nil let hasChineseEpisode = stem.range(of: "第\\s*\\d{1,3}\\s*集", options: .regularExpression) != nil let hasSeriesTitle = stem.localizedCaseInsensitiveContains(item.title) let needsRename = !(hasSeriesTitle && (hasStandardEpisode || hasChineseEpisode)) let ext = (item.file.name as NSString).pathExtension do { let seasonFolderName = "Season \(season)" let seasonFolderID = try await mediaCategoryFolderID(named: seasonFolderName, parentID: workFolderID, taskID: taskID) let detail = try await api.fsDetail(fileID: item.file.id) let currentParentID = detail.firstIDDeep(["parentId", "parent_id", "parentFileId"]) let relatedIDs = Array(Set(([item.file] + files.subtitles).map(\.id))) if currentParentID != seasonFolderID { _ = try await api.fsMove(fileIDs: relatedIDs, parentID: seasonFolderID) updateMediaLibraryTask(taskID, currentWriting: seasonFolderName, log: "已归档剧集至:\(seasonFolderName)") } guard needsRename else { return } let baseName = safeCloudName("\(item.title).\(item.year).\(String(format: "S%02dE%02d", season, episode))") let targetName = ext.isEmpty ? baseName : "\(baseName).\(ext)" guard item.file.name != targetName else { return } _ = try await api.fsRename(fileID: item.file.id, newName: targetName) updateMediaLibraryTask(taskID, currentWriting: targetName, log: "已规范化剧集文件:\(targetName)") } catch { updateMediaLibraryTask(taskID, log: "剧集文件重命名失败:\(item.file.name)") } } private func normalizeMediaLibraryFolder(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID) async { guard let tmdbID = item.tmdbID, let libraryID = mediaLibraryTasks.first(where: { $0.id == taskID })?.libraryID, let library = mediaLibraries.first(where: { $0.id == libraryID }), let folderID = try? await resolvedMediaWorkFolderID(for: item, files: files, library: library), normalizedMediaFolderIDs[taskID, default: []].insert(folderID).inserted else { return } // A media-library source is a container, never a work itself. Renaming it // would invalidate the library configuration and is intentionally skipped. let sourceIDs = library.sources.compactMap(\.rootID) guard !sourceIDs.contains(folderID) else { return } guard let detail = try? await api.fsDetail(fileID: folderID), let currentName = detail.firstStringDeep(["name", "fileName", "resName"]) else { return } let targetName = mediaWorkFolderName(item, tmdbID: tmdbID) guard currentName != targetName else { return } do { _ = try await api.fsRename(fileID: folderID, newName: targetName) updateMediaLibraryTask(taskID, currentWriting: targetName, log: "已规范化媒体目录:\(targetName)") } catch { updateMediaLibraryTask(taskID, log: "媒体目录重命名失败:\(currentName)") } } private func organizeMediaLibraryFolder(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID) async { guard let tmdbID = item.tmdbID, let libraryID = mediaLibraryTasks.first(where: { $0.id == taskID })?.libraryID, let library = mediaLibraries.first(where: { $0.id == libraryID }) else { return } guard let folderID = try? await resolvedMediaWorkFolderID(for: item, files: files, library: library), !library.sources.compactMap(\.rootID).contains(folderID), organizedMediaFolderIDs[taskID, default: []].insert(folderID).inserted else { return } do { let mediaKind = item.mediaKind ?? (library.kind == .movies ? .movie : .tv) let details = try await api.tmdbDetails(id: tmdbID, mediaKind: mediaKind, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) let category = mediaCategoryName(mediaKind: mediaKind, details: details) guard let source = categorySource(for: item.file, in: library) else { return } let categoryRootID = try await categoryRootID(for: source) let mediaTypeID = try await mediaCategoryFolderID(named: mediaLibraryTypeFolder(for: mediaKind), parentID: categoryRootID, taskID: taskID) let categoryID = try await mediaCategoryFolderID(named: category, parentID: mediaTypeID, taskID: taskID) let year = mediaLibraryYear(for: item) let yearID = try await mediaCategoryFolderID(named: year, parentID: categoryID, taskID: taskID) let folderDetail = try await api.fsDetail(fileID: folderID) let currentParentID = folderDetail.firstIDDeep(["parentId", "parent_id", "parentFileId"]) let finalName = mediaWorkFolderName(item, tmdbID: tmdbID) let currentName = folderDetail.firstStringDeep(["name", "fileName", "resName"]) if currentName != finalName { _ = try await api.fsRename(fileID: folderID, newName: finalName) } if currentParentID != yearID { _ = try await api.fsMove(fileIDs: [folderID], parentID: yearID) } updateMediaLibraryTask(taskID, currentWriting: "\(mediaLibraryTypeFolder(for: mediaKind))/\(category)/\(year)/\(finalName)", log: "已分类整理:\(item.title) -> \(category)/\(year)") } catch { updateMediaLibraryTask(taskID, log: "分类整理失败:\(item.title)(\(error.localizedDescription))") } } private func mediaCategoryName(mediaKind: TMDBMediaKind, details: JSONValue) -> String { let language = (details.firstString(["original_language", "originalLanguage"]) ?? "").lowercased() let genres = Set((details["genres"]?.arrayValue ?? []).compactMap { $0.firstInt(["id"]) }) if let rule = mediaCategoryRules.first(where: { $0.matches(mediaKind: mediaKind, genreIDs: genres, language: language) }) { return normalizedMediaCategoryName(rule.name, mediaKind: mediaKind) } return mediaKind == .movie ? "其他电影" : "其他剧集" } private func normalizedMediaCategoryName(_ name: String, mediaKind: TMDBMediaKind) -> String { switch (mediaKind, name) { case (.movie, "华语电影"): return "国产电影" case (.movie, "日语电影"), (.movie, "韩语电影"): return "日韩电影" case (.movie, "外语电影"): return "其他电影" case (.tv, "国产剧"): return "国产剧集" case (.tv, "日剧"), (.tv, "韩剧"), (.tv, "泰剧"), (.tv, "日韩泰剧"): return "日韩剧集" case (.tv, "欧美剧"): return "欧美剧集" case (.tv, "其他剧"): return "其他剧集" default: return name } } private func mediaLibraryTypeFolder(for mediaKind: TMDBMediaKind) -> String { mediaKind == .movie ? "电影" : "剧集" } private func mediaWorkFolderName(_ item: MediaLibraryItem, tmdbID: Int) -> String { return safeCloudName("\(item.title) (\(mediaLibraryYear(for: item))) {tmdb-\(tmdbID)}") } private func mediaLibraryYear(for item: MediaLibraryItem) -> String { item.year.isEmpty ? "0000" : item.year } private func resolvedMediaWorkFolderID(for item: MediaLibraryItem, files: MediaSidecarFiles, library: MediaLibraryDefinition) async throws -> String? { var currentID = item.file.isDirectory ? item.file.id : files.parentID let sourceIDs = Set(library.sources.compactMap(\.rootID)) guard let initialID = currentID, !sourceIDs.contains(initialID) else { return nil } var fallbackID = initialID while let folderID = currentID, !sourceIDs.contains(folderID) { let detail = try await api.fsDetail(fileID: folderID) let name = detail.firstStringDeep(["name", "fileName", "resName"]) ?? "" if Self.embeddedTMDBID(in: "/\(name)/media") != nil { return folderID } let parentID = detail.firstIDDeep(["parentId", "parent_id", "parentFileId"]) if parentID == nil || sourceIDs.contains(parentID ?? "") { return folderID } fallbackID = folderID currentID = parentID } return fallbackID } private func categorySource(for file: CloudFile, in library: MediaLibraryDefinition) -> MediaLibrarySource? { let path = file.cloudPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let matching = library.sources.filter { source in let sourcePath = source.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) return sourcePath.isEmpty || path == sourcePath || path.hasPrefix(sourcePath + "/") } return matching.max { $0.path.count < $1.path.count } ?? library.sources.first } private func categoryRootID(for source: MediaLibrarySource) async throws -> String { guard let sourceID = source.rootID else { throw GuangyaAPIError.missingField("媒体库目录 ID") } let categoryNames = Set(mediaCategoryRules.map(\.name) + [ "电影", "剧集", "儿童节目", "国产电影", "日韩电影", "欧美电影", "其他电影", "国产剧集", "日韩剧集", "欧美剧集", "其他剧集", "华语电影", "日语电影", "韩语电影", "外语电影", "国产剧", "日剧", "韩剧", "泰剧", "日韩泰剧", "欧美剧", "其他剧" ]) let sourceName = source.path.split(separator: "/").last.map(String.init) ?? source.path guard categoryNames.contains(sourceName) else { return sourceID } let detail = try await api.fsDetail(fileID: sourceID) return detail.firstIDDeep(["parentId", "parent_id", "parentFileId"]) ?? sourceID } private func mediaCategoryFolderID(named name: String, parentID: String, taskID: UUID) async throws -> String { let cacheKey = "\(parentID):\(name)" if let cached = mediaCategoryFolderIDs[taskID]?[cacheKey] { return cached } let response = try await api.fsFiles(parentID: parentID, page: 0, pageSize: 1_000) if let existing = extractFiles(from: response).first(where: { $0.isDirectory && $0.name == name }) { mediaCategoryFolderIDs[taskID, default: [:]][cacheKey] = existing.id return existing.id } let created = try await api.fsCreateDir(name: name, parentID: parentID) if let id = created.firstStringDeep(["fileId", "file_id", "id"]) { mediaCategoryFolderIDs[taskID, default: [:]][cacheKey] = id return id } let refreshed = try await api.fsFiles(parentID: parentID, page: 0, pageSize: 1_000) guard let createdFolder = extractFiles(from: refreshed).first(where: { $0.isDirectory && $0.name == name }) else { throw GuangyaAPIError.missingField("分类目录 \(name)") } mediaCategoryFolderIDs[taskID, default: [:]][cacheKey] = createdFolder.id return createdFolder.id } private func mediaNFO(for item: MediaLibraryItem) -> String { let root = item.mediaKind == .tv ? "tvshow" : "movie" let uniqueID = item.tmdbID.map { "\($0)" } ?? "" let collection = item.collectionName.map { name in let identifier = item.collectionID.map { "\($0)" } ?? "" return "\(name.xmlEscaped)\(identifier)" } ?? "" return "<\(root)>\(uniqueID)\(item.title.xmlEscaped)\(item.originalTitle.xmlEscaped)\(item.overview.xmlEscaped)\(item.releaseDate.xmlEscaped)\(collection)" } private func uploadScrapeData(_ data: Data, name: String, contentType: String, parentID: String?) async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent("guangya-media-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent(name) try data.write(to: url, options: .atomic) _ = try await api.fileUpload(url: url, parentID: parentID, contentType: contentType) } 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() let sessionID = UUID() scanSessionID = sessionID activeScanRequest = request scanResult = nil liveScanItems = [] liveScannedFilesByID = [:] scanProgress = ScanProgress(phase: "正在准备扫描…") isScanning = true scanTask = Task { [weak self] in guard let self else { return } defer { if self.scanSessionID == sessionID { self.isScanning = false self.scanTask = nil } } do { let result = try await self.runScan(request) guard self.scanSessionID == sessionID, !Task.isCancelled else { return } self.scanResult = result self.scanProgress.phase = "扫描完成" } catch is CancellationError { guard self.scanSessionID == sessionID else { return } self.scanResult = ScanResult( request: request, items: self.liveScanItems, foldersScanned: self.scanProgress.foldersVisited, filesScanned: self.scanProgress.filesVisited, skippedFolders: 0 ) self.scanProgress.phase = "扫描已取消,已保留当前结果" } catch { guard self.scanSessionID == sessionID else { return } self.scanProgress.phase = "扫描失败" self.errorMessage = error.localizedDescription } } } func cancelScan() { guard isScanning, let request = activeScanRequest else { return } // Publish the current snapshot before cancelling network traversal. This // makes cancellation immediate and prevents later task callbacks from // clearing or replacing results already shown to the user. scanResult = ScanResult( request: request, items: liveScanItems, foldersScanned: scanProgress.foldersVisited, filesScanned: scanProgress.filesVisited, skippedFolders: 0 ) scanProgress.phase = "扫描已取消,已保留当前结果" scanSessionID = nil activeScanRequest = nil isScanning = false scanTask?.cancel() scanTask = nil } 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 guard folders.count > 1 else { return nil } return SimilarFolderGroup(id: key, folders: folders.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending }) } .sorted { lhs, rhs in (lhs.folders.first?.cloudPath ?? "").localizedStandardCompare(rhs.folders.first?.cloudPath ?? "") == .orderedAscending } 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: "/") } for child in children { liveScannedFilesByID[child.id] = child } refreshLiveScanItemsFromDiscoveredFiles() // 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 refreshLiveScanItemsFromDiscoveredFiles() { guard let request = activeScanRequest else { return } let discovered = Array(liveScannedFilesByID.values) switch request.kind { case .emptyFolders: break case .duplicates: let candidates = discovered.filter { !$0.isDirectory && $0.gcid?.isEmpty == false } let groups = Dictionary(grouping: candidates, by: { $0.gcid! }) .compactMap { gcid, files -> DuplicateGroup? in guard files.count > 1 else { return nil } return DuplicateGroup(id: gcid, files: files.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending }) } .sorted { $0.files.count > $1.files.count } liveScanItems = groups.map(ScanItem.duplicate) case .similarFolders: let groups = Dictionary(grouping: discovered.filter(\.isDirectory), by: { normalizedFolderName($0.name) }) .compactMap { key, folders -> SimilarFolderGroup? in guard folders.count > 1 else { return nil } return SimilarFolderGroup(id: key, folders: folders.sorted { $0.cloudPath.localizedStandardCompare($1.cloudPath) == .orderedAscending }) } .sorted { lhs, rhs in (lhs.folders.first?.cloudPath ?? "").localizedStandardCompare(rhs.folders.first?.cloudPath ?? "") == .orderedAscending } liveScanItems = groups.map(ScanItem.similar) } } 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) } private func allGlobalSearchFiles() async throws -> [CloudFile] { async let allItems = allGlobalSearchItems(resType: nil) async let folders = allGlobalSearchItems(resType: 2) let collected = try await allItems + folders return Array(Dictionary(grouping: collected, by: \.id).compactMap { $0.value.first }) } private func allGlobalSearchItems(resType: Int?) async throws -> [CloudFile] { var page = 0 var collected: [CloudFile] = [] while true { try Task.checkCancellation() let response = try await api.fsFiles(parentID: "*", page: page, pageSize: 10_000, orderBy: 0, sortType: 0, resType: resType) let batch = extractFiles(from: response) collected += batch let total = response.firstIntDeep(["total", "totalCount", "count"]) if batch.count < 10_000 || (total != nil && collected.count >= total!) { break } page += 1 } return collected } private func fastTransferChildren(parentID: String?) async throws -> [CloudFile] { var page = 0 var values: [CloudFile] = [] while true { let response = try await api.fsFiles(parentID: parentID, page: page, pageSize: 1_000) let batch = await Task.detached(priority: .utility) { Self.extractFastTransferFiles(from: response) }.value values += batch let total = response.firstIntDeep(["total", "totalCount", "count"]) if batch.count < 1_000 || (total != nil && values.count >= total!) { break } page += 1 } return await Task.detached(priority: .utility) { Array(Dictionary(grouping: values, by: \.id).compactMap { $0.value.first }) }.value } private nonisolated static func extractFastTransferFiles(from value: JSONValue) -> [CloudFile] { var files: [CloudFile] = [] var seen = Set() func visit(_ value: JSONValue) { if let file = CloudFile(json: value), seen.insert(file.id).inserted { files.append(file) } if let object = value.objectValue { object.values.forEach(visit) } if let array = value.arrayValue { array.forEach(visit) } } visit(value) return files } private func fastTransferTargetDirectory(for entry: FastTransferEntry, parentID: String?, createDirectories: Bool, cache: inout [String: String]) async throws -> String? { let components = entry.directoryPath .split(separator: "/") .map(String.init) .filter { !$0.isEmpty && $0 != "." } guard !components.isEmpty else { return parentID } guard createDirectories else { throw FastTransferError.invalidJSON("\(entry.path) 包含目录,请开启自动创建多级目录") } var currentID = parentID for component in components { guard component != ".." else { throw FastTransferError.invalidJSON("目录不能包含 ..") } let cacheKey = "\(currentID ?? "__root__")/\(component)" if let cached = cache[cacheKey] { currentID = cached continue } let children = try await fastTransferChildren(parentID: currentID) if let existing = children.first(where: { $0.name == component && $0.isDirectory }) { currentID = existing.id } else if children.contains(where: { $0.name == component }) { throw FastTransferError.invalidJSON("\(component) 已被同名文件占用") } else { let created = try await api.fsCreateDir(name: component, parentID: currentID) if let id = created.firstStringDeep(["fileId", "file_id", "id", "resId"]) { currentID = id } else if let refreshed = try await fastTransferChildren(parentID: currentID).first(where: { $0.name == component && $0.isDirectory }) { currentID = refreshed.id } else { throw GuangyaAPIError.missingField("目录 \(component) ID") } } if let currentID { cache[cacheKey] = currentID } } return currentID } private nonisolated static func fastTransferString(in values: [String: Any], keys: [String]) -> String? { for key in keys { if let value = values[key] as? String, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return value.trimmingCharacters(in: .whitespacesAndNewlines) } } return nil } private nonisolated static func fastTransferSize(in values: [String: Any]) -> Int64 { if let value = values["size"] as? NSNumber { return value.int64Value } if let value = values["size"] as? String, let size = Int64(value.trimmingCharacters(in: .whitespacesAndNewlines)) { return size } return -1 } private nonisolated static func validatedFastTransferHash(_ value: String?, length: Int) -> String? { guard let value else { return nil } let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() guard normalized.count == length, normalized.range(of: "^[0-9A-F]{\(length)}$", options: .regularExpression) != nil else { return nil } return normalized } private nonisolated static func validatedFastTransferMD5(_ value: String?) -> String? { guard let value else { return nil } let raw = value.trimmingCharacters(in: .whitespacesAndNewlines) let hex = raw.replacingOccurrences(of: "-", with: "") if hex.count == 32, hex.range(of: "^[0-9A-Fa-f]{32}$", options: .regularExpression) != nil { return hex.lowercased() } guard raw.count == 32, raw.range(of: "^[0-9A-Za-z+/_=-]{32}$", options: .regularExpression) != nil else { return nil } return raw.lowercased() } private nonisolated static func normalizedFastTransferPath(_ value: String) -> String { value.replacingOccurrences(of: "\\", with: "/") .split(separator: "/") .map(String.init) .filter { !$0.isEmpty && $0 != "." } .joined(separator: "/") } private nonisolated static func fastTransferLocalCandidates(from urls: [URL]) throws -> [LocalFastTransferCandidate] { var candidates: [LocalFastTransferCandidate] = [] for root in urls { let resource = try root.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) if resource.isRegularFile == true { candidates.append(LocalFastTransferCandidate(url: root, path: root.lastPathComponent)) continue } guard resource.isDirectory == true else { continue } let base = root.lastPathComponent let keys: Set = [.isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey] guard let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: Array(keys), options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { continue } for case let url as URL in enumerator { let values = try url.resourceValues(forKeys: keys) guard values.isRegularFile == true, values.isSymbolicLink != true else { continue } let relative = url.path.replacingOccurrences(of: root.path + "/", with: "") candidates.append(LocalFastTransferCandidate(url: url, path: "\(base)/\(relative)")) } } return candidates.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } } private nonisolated static func localFileSize(at url: URL) -> Int64 { (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) ?? 0 } private nonisolated static func calculateLocalHashes(at url: URL) throws -> (md5: String, gcid: String) { let fileSize = localFileSize(at: url) let chunkSize: Int switch fileSize { case ...0x8000000: chunkSize = 262_144 case ...0x10000000: chunkSize = 524_288 case ...0x20000000: chunkSize = 1_048_576 default: chunkSize = 2_097_152 } let handle = try FileHandle(forReadingFrom: url) defer { try? handle.close() } var combined = Data() var md5Hasher = Insecure.MD5() while true { let chunk = try handle.read(upToCount: chunkSize) ?? Data() if chunk.isEmpty { break } md5Hasher.update(data: chunk) var chunkHasher = Insecure.SHA1() chunkHasher.update(data: chunk) combined.append(contentsOf: chunkHasher.finalize()) } let md5 = md5Hasher.finalize().map { String(format: "%02x", $0) }.joined() let gcid = Insecure.SHA1.hash(data: combined).map { String(format: "%02X", $0) }.joined() return (md5, gcid) } 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 parsedTMDBInfo(for item: MediaLibraryItem) -> ParsedMediaName { let path = item.file.cloudPath as NSString let folderName = (path.deletingLastPathComponent as NSString).lastPathComponent return mediaParse(fileName: item.file.name, folderName: folderName.isEmpty ? nil : folderName) } 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 mediaParse(fileName: String, folderName: String?) -> ParsedMediaName { let file = parseMediaName(fileName) guard let folderName = folderName?.trimmingCharacters(in: .whitespacesAndNewlines), !folderName.isEmpty else { return file } let folder = parseMediaName(folderName) let folderHasTMDBID = Self.embeddedTMDBID(in: "/\(folderName)/media") != nil let genericTitles: Set = ["video", "movie", "sample", "trailer", "bdmv", "video ts", "certificate", "disc", "feature", "00000", "00001"] let fileTitle = file.title.trimmingCharacters(in: .whitespacesAndNewlines) let folderTitle = folder.title.trimmingCharacters(in: .whitespacesAndNewlines) let fileIsGeneric = genericTitles.contains(fileTitle.lowercased()) || fileTitle.count < 2 || fileTitle.range(of: "^0*\\d{1,4}$", options: .regularExpression) != nil let folderIsContainer = folderTitle.range(of: "(?i)(?:电影|电视剧|剧集|动画|合集|资源|影视|发布|专区|系列|作品|collection|movies?|shows?)$", options: .regularExpression) != nil let folderIsUseful = !folderTitle.isEmpty && !genericTitles.contains(folderTitle.lowercased()) && !folderIsContainer // Prefer an actual filename whenever it already carries a title. This is // important for collection folders such as “林正英电影(1)”: that label is // not a TMDB work. Folders win only when they explicitly identify a work // (TMDB ID/year) or the contained filename is merely an episode number. let useFolderTitle = folderIsUseful && (folderHasTMDBID || (folder.year != nil && !folderName.localizedCaseInsensitiveContains("发布")) || fileIsGeneric) let selected = useFolderTitle ? folder : file return ParsedMediaName( title: selected.title, year: selected.year ?? (useFolderTitle ? file.year : folder.year), season: file.season ?? folder.season, episode: file.episode ?? folder.episode, isEpisode: file.isEpisode || folder.isEpisode, resolution: file.resolution ?? folder.resolution, source: file.source ?? folder.source, videoCodec: file.videoCodec ?? folder.videoCodec, audio: file.audio ?? folder.audio, isDiscStructure: file.isDiscStructure || folder.isDiscStructure ) } private func parseMediaName(_ name: String) -> ParsedMediaName { let extensionName = (name as NSString).pathExtension.lowercased() // Folder names such as "Batman.Returns.1992" must not lose the last // component merely because dot-separated release names resemble files. let stem = CloudFile.supportedVideoExtensions.contains(extensionName) ? (name as NSString).deletingPathExtension : name // Brackets are normally release metadata. Some animation releases put // every field in brackets though, so retain the meaningful title token // only when stripping brackets would leave no usable filename at all. let withoutBrackets = stem .replacingOccurrences(of: "【[^】]*】|\\[[^\\]]*\\]|([^)]*)|\\([^)]*\\)|\\{[^}]*\\}", with: " ", options: .regularExpression) let unbracketed = withoutBrackets.replacingOccurrences(of: "[._]+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines) // Release groups commonly use [中文标题][English Alias][年份][集号]. // When a meaningful bracket title exists it is more reliable than any // residual publisher text outside the brackets. let bracketedTitle = bracketedReleaseTitle(in: stem) let normalized = (bracketedTitle ?? (unbracketed.isEmpty ? stem : unbracketed)) .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 } if season == nil { let episodeOnlyPatterns = ["(?i)\\b(?:E|EP|Episode)[ ._-]*(\\d{1,3})\\b", "第\\s*(\\d{1,3})\\s*[集话]", "(?:^|\\s)(\\d{1,3})$"] for pattern in episodeOnlyPatterns { guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)), match.numberOfRanges >= 2 else { continue } season = 1; episode = Int((normalized as NSString).substring(with: match.range(at: 1))); break } } if season == nil, let bracketEpisode = bracketedEpisode(in: stem) { season = 1 episode = bracketEpisode } let yearRegex = try? NSRegularExpression(pattern: "\\b(19\\d{2}|20\\d{2})\\b") let yearSource = normalized == stem ? stem : "\(normalized) \(stem)" let year = yearRegex?.firstMatch(in: yearSource, range: NSRange(yearSource.startIndex..., in: yearSource)).flatMap { Int((yearSource as NSString).substring(with: $0.range(at: 1))) } // Once release metadata starts, everything after it is normally codec, // source, frame-rate, language or uploader information. This keeps the // title stable regardless of the order or delimiter used by a release. let metadataBoundary = "(?i)\\b(?:S\\d{1,2}[ ._-]*E\\d{1,3}|\\d{1,2}x\\d{1,3}|(?:E|EP|Episode)[ ._-]*\\d{1,3}|2160[pi]|1080[pi]|720[pi]|480[pi]|4k|web[- ]?(?:dl|rip)?|bluray|b[dr]rip|remux|hdtv|dvd|x26[45]|h\\.?26[45]|hevc|av1|avc|vc-1|aac|ac3|eac3|flac|truehd|dts|ddp?|atmos|hdr|dv|\\d{1,2}bit|\\d(?:\\.\\d)?audio|\\d{2,3}fps|\\d{2,3}帧(?:率)?|bonus[ ._-]*disc|(?:chinese|chn|japanese|jpn|jap|korean|kor|english|eng|french|fre|german|ger|spanish|spa|italian|ita|russian|rus|mandarin|cantonese|hkg|hong[ .-]?kong|usa|uk|multi|dual|dubbed|subbed)|第\\s*\\d{1,2}\\s*季|第\\s*\\d{1,3}\\s*[集话]|全\\s*\\d{1,3}\\s*集|(?:国语|普通话|粤语)(?:配音)?|(?:简体|繁体|中英|双语|中文)字幕)\\b" let titleSource: String if let range = normalized.range(of: metadataBoundary, options: .regularExpression) { titleSource = String(normalized[.. 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 if let chineseTitle = preferredChineseTitle(in: title) { title = chineseTitle } 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 preferredChineseTitle(in value: String) -> String? { // Chinese release folders commonly use "中文名 English Alias.年份". // TMDB's Chinese-first lookup is much more reliable when the alias is // retained only as context rather than concatenated into the title. let pattern = "^\\s*([\\p{Han}0-9::,,、!!??《》·\\-]+)(?=\\s+[A-Za-z])" guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)), match.range(at: 1).location != NSNotFound else { return nil } let title = (value as NSString).substring(with: match.range(at: 1)).trimmingCharacters(in: .whitespacesAndNewlines) return title.isEmpty ? nil : title } private func bracketedReleaseTitle(in stem: String) -> String? { let pattern = "\\[([^\\]]+)\\]|【([^】]+)】" guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } let range = NSRange(stem.startIndex..., in: stem) let tokens = regex.matches(in: stem, range: range).compactMap { match -> String? in let contentRange = match.range(at: match.range(at: 1).location == NSNotFound ? 2 : 1) guard contentRange.location != NSNotFound else { return nil } return (stem as NSString).substring(with: contentRange).trimmingCharacters(in: .whitespacesAndNewlines) } func isMetadata(_ value: String) -> Bool { value.range(of: "(?i)^(?:gm[- ]?team|国漫|动漫|动画|完结|全集|合集|全?\\s*\\d{1,3}\\s*集|更新至\\s*\\d{1,3}\\s*集|\\d{1,3}\\s*(?:fps|帧(?:率)?)(?:版本)?|(?:国语|普通话|粤语)(?:配音)?(?:[++](?:简体|繁体|中英|双语|中文)字幕)?|(?:简体|繁体|中英|双语|中文)字幕|\\d{1,4}|19\\d{2}|20\\d{2}|2160[pi]|1080[pi]|720[pi]|480[pi]|4k|avc|hevc|h\\.?26[45]|x26[45]|gb|big5|chs|cht|aac|ac3|dts|flac|web[- ]?(?:dl|rip)?|bluray|remux|hdtv|dvd|jpn|jap|kor|chinese|chn|hkg|eng|multi)$", options: .regularExpression) != nil } let candidates = tokens.filter { !$0.isEmpty && !isMetadata($0) } if let chinese = candidates.first(where: { $0.range(of: "\\p{Han}", options: .regularExpression) != nil }) { return chinese } return candidates.first } private func bracketedEpisode(in stem: String) -> Int? { guard let regex = try? NSRegularExpression(pattern: "\\[(\\d{1,4})\\]|【(\\d{1,4})】") else { return nil } let range = NSRange(stem.startIndex..., in: stem) for match in regex.matches(in: stem, range: range).reversed() { let valueRange = match.range(at: match.range(at: 1).location == NSNotFound ? 2 : 1) guard valueRange.location != NSNotFound, let value = Int((stem as NSString).substring(with: valueRange)), (1...999).contains(value) else { continue } return value } return nil } 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 { var normalized = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) // Cloud drives often create these suffixes when a folder is copied. They // do not change the source folder's identity for cleanup review. normalized = normalized.replacingOccurrences( of: "(?i)(?:[\\s._-]*(?:copy|duplicate|backup|副本|复制|拷贝|备份))(?:[\\s._-]*\\d+)?$", with: "", options: .regularExpression ) normalized = normalized.replacingOccurrences( of: "(?:[((\\[]\\s*\\d{1,3}\\s*[))\\]])$", with: "", options: .regularExpression ) return normalized.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] { extractCloudFiles(from: value) } 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: ">") } }