From 0df8013d3e92e6f3f0c453d5c02b16bbd7866b7b Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 00:50:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=99=A8=EF=BC=8C=E7=BB=9F=E4=B8=80=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E6=92=AD=E6=94=BE=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器 - 播放器选择器优化为 Grid 布局,显示真实应用图标 - 右键菜单合并为"播放"菜单,直接列出所有播放器 - 详情页剧集列表增加右键菜单 - 媒体库所有上下文菜单统一播放入口 - 修复启动时自动清理已删除文件的秒传记录 - 添加 prepareAndPerformFastTransfer 静态方法 --- guangya_mac.xcodeproj/project.pbxproj | 26 +- guangya_mac/AppModel.swift | 2438 +++++++++++++++++++++++-- guangya_mac/ContentView.swift | 1664 +++++++++++++++-- guangya_mac/DualPanel.swift | 519 ++++++ guangya_mac/GuangyaAPI.swift | 137 +- guangya_mac/MediaLibraryPage.swift | 2099 +++++++++++++++++++-- guangya_mac/MediaLibraryStore.swift | 184 +- guangya_mac/Models.swift | 288 ++- guangya_mac/WorkspaceShell.swift | 357 +++- guangya_mac/guangya_macApp.swift | 9 + 10 files changed, 7121 insertions(+), 600 deletions(-) create mode 100644 guangya_mac/DualPanel.swift diff --git a/guangya_mac.xcodeproj/project.pbxproj b/guangya_mac.xcodeproj/project.pbxproj index 9c40a24..1f608d9 100644 --- a/guangya_mac.xcodeproj/project.pbxproj +++ b/guangya_mac.xcodeproj/project.pbxproj @@ -14,16 +14,18 @@ 8C0E0B522C40000100A10001 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B422C40000100A10001 /* Models.swift */; }; 8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B432C40000100A10001 /* GuangyaAPI.swift */; }; 8C0E0B542C40000100A10001 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B442C40000100A10001 /* AppModel.swift */; }; + 8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B452C40000100A10001 /* 光鸭.svg */; }; 8C0E0B562C40000100A10001 /* FileMetadataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B462C40000100A10001 /* FileMetadataCache.swift */; }; 8C0E0B572C40000100A10001 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B472C40000100A10001 /* AppTheme.swift */; }; 8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B482C40000100A10001 /* WorkspaceShell.swift */; }; 8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B612C40000100A10001 /* BatchRenameTool.swift */; }; 8C0E0B632C40000100A10001 /* MediaLibraryPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B622C40000100A10001 /* MediaLibraryPage.swift */; }; 8C0E0B652C40000100A10001 /* MediaLibraryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B642C40000100A10001 /* MediaLibraryStore.swift */; }; - 8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B452C40000100A10001 /* 光鸭.svg */; }; + E25B80319B73188D1EA1EC71 /* DualPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FE17776E66D537CC5965522 /* DualPanel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 0FE17776E66D537CC5965522 /* DualPanel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DualPanel.swift; sourceTree = ""; }; 8C0E0B332C40000100A10001 /* guangya_mac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = guangya_mac.app; sourceTree = BUILT_PRODUCTS_DIR; }; 8C0E0B362C40000100A10001 /* guangya_macApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = guangya_macApp.swift; sourceTree = ""; }; 8C0E0B372C40000100A10001 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -85,6 +87,7 @@ 8C0E0B452C40000100A10001 /* 光鸭.svg */, 8C0E0B382C40000100A10001 /* Assets.xcassets */, 8C0E0B392C40000100A10001 /* Preview Content */, + 0FE17776E66D537CC5965522 /* DualPanel.swift */, ); path = guangya_mac; sourceTree = ""; @@ -125,7 +128,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2630; - LastUpgradeCheck = 2630; + LastUpgradeCheck = 2640; TargetAttributes = { 8C0E0B322C40000100A10001 = { CreatedOnToolsVersion = 26.3; @@ -157,7 +160,7 @@ files = ( 8C0E0B402C40000100A10001 /* Assets.xcassets in Resources */, 8C0E0B412C40000100A10001 /* Preview Assets.xcassets in Resources */, - 8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */, + 8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -170,15 +173,16 @@ files = ( 8C0E0B3E2C40000100A10001 /* guangya_macApp.swift in Sources */, 8C0E0B3F2C40000100A10001 /* ContentView.swift in Sources */, - 8C0E0B522C40000100A10001 /* Models.swift in Sources */, - 8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */, - 8C0E0B542C40000100A10001 /* AppModel.swift in Sources */, + 8C0E0B522C40000100A10001 /* Models.swift in Sources */, + 8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */, + 8C0E0B542C40000100A10001 /* AppModel.swift in Sources */, 8C0E0B562C40000100A10001 /* FileMetadataCache.swift in Sources */, 8C0E0B572C40000100A10001 /* AppTheme.swift in Sources */, 8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */, 8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */, 8C0E0B632C40000100A10001 /* MediaLibraryPage.swift in Sources */, 8C0E0B652C40000100A10001 /* MediaLibraryStore.swift in Sources */, + E25B80319B73188D1EA1EC71 /* DualPanel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -194,6 +198,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"guangya_mac/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -205,7 +210,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.guangya-mac; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.guangya-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; @@ -222,6 +227,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"guangya_mac/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -233,7 +239,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.guangya-mac; + PRODUCT_BUNDLE_IDENTIFIER = "com.example.guangya-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; @@ -273,6 +279,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -296,6 +303,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -333,6 +341,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -349,6 +358,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; }; diff --git a/guangya_mac/AppModel.swift b/guangya_mac/AppModel.swift index cb08e2d..1f45eb0 100644 --- a/guangya_mac/AppModel.swift +++ b/guangya_mac/AppModel.swift @@ -1,7 +1,97 @@ 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 @@ -16,6 +106,8 @@ final class AppModel: ObservableObject { @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 @@ -36,13 +128,16 @@ final class AppModel: ObservableObject { @Published var batchRenameSelection: [CloudFile] = [] @Published var mediaLibraryTarget: CloudFile? @Published var mediaLibraries: [MediaLibraryDefinition] = [] - @Published var isScanningMediaLibrary = false - @Published var mediaLibraryScanProgress = MediaLibraryScanProgress() - @Published var isWritingMediaMetadata = false - @Published var mediaMetadataWriteProgress = MediaLibraryScanProgress() - @Published var mediaLibraryLiveLibraryID: String? - @Published var mediaLibraryLiveItems: [MediaLibraryItem] = [] + @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? @@ -56,6 +151,38 @@ final class AppModel: ObservableObject { @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 = "" @@ -83,16 +210,40 @@ final class AppModel: ObservableObject { private let mediaLibraryStore = MediaLibraryStore.shared private var isHandlingAuthorizationExpiry = false private var scanTask: Task? - private var mediaMetadataWriteTask: Task? - private var mediaMetadataWriteQueue: [(MediaLibraryItem, MediaSidecarFiles)] = [] - private var mediaMetadataWrittenKeys = Set() - private var mediaMetadataWriteFailures = 0 + 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 @@ -157,6 +308,55 @@ final class AppModel: ObservableObject { 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) } @@ -206,7 +406,8 @@ final class AppModel: ObservableObject { } func deleteSelectedFiles(_ ids: Set) async { - let targets = files.filter { ids.contains($0.id) } + 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) @@ -214,6 +415,7 @@ final class AppModel: ObservableObject { // 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 @@ -230,6 +432,7 @@ final class AppModel: ObservableObject { // 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 @@ -278,6 +481,541 @@ final class AppModel: ObservableObject { 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) } } @@ -320,19 +1058,56 @@ final class AppModel: ObservableObject { } } + + + + + + // 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) - let candidates = ["IINA", "VLC"] - for name in candidates { - if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: name == "IINA" ? "com.colliderli.iina" : "org.videolan.vlc") { + for p in Self.supportedPlayers { + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: p.bundleID) { try await NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: .init()) - lastActionMessage = "已使用 \(name) 打开“\(file.name)”" return } } NSWorkspace.shared.open(url) - lastActionMessage = "未发现 IINA 或 VLC,已交给系统默认播放器" } } @@ -391,15 +1166,25 @@ final class AppModel: ObservableObject { } func loadMediaLibraries() async { + if !didAttemptCloudMediaLibraryRestore, await mediaLibraryStore.isEmpty() { + didAttemptCloudMediaLibraryRestore = true + _ = try? await restoreMediaLibraryBackupFromCloud(reportStatus: false) + } mediaLibraries = await mediaLibraryStore.libraries() + await refreshMediaLibraryStatistics() } - func createMediaLibrary(name: String, rootID: String?, rootPath: String, kind: MediaLibraryKind, recursive: Bool) async -> MediaLibraryDefinition { - await createMediaLibrary(name: name, sources: [MediaLibrarySource(rootID: rootID, path: rootPath)], kind: kind, recursive: recursive) + func refreshMediaLibraryStatistics() async { + mediaLibraryStatistics = await mediaLibraryStore.statistics() + mediaLibraryStatisticsByLibraryID = await mediaLibraryStore.statisticsByLibraryID() } - func createMediaLibrary(name: String, sources: [MediaLibrarySource], kind: MediaLibraryKind, recursive: Bool) async -> MediaLibraryDefinition { - let library = MediaLibraryDefinition(id: UUID().uuidString, name: name.trimmingCharacters(in: .whitespacesAndNewlines), sources: sources, kind: kind, recursive: recursive, updatedAt: nil) + 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 @@ -415,28 +1200,603 @@ final class AppModel: ObservableObject { 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 scanMediaLibrary(_ library: MediaLibraryDefinition) async throws -> [MediaLibraryItem] { - guard !isScanningMediaLibrary else { return await cachedMediaLibraryItems(libraryID: library.id) } - isScanningMediaLibrary = true - mediaLibraryScanProgress = MediaLibraryScanProgress(phase: "正在读取云端目录…") - mediaMetadataWrittenKeys = [] - mediaMetadataWriteFailures = 0 - mediaMetadataWriteProgress = MediaLibraryScanProgress() - defer { - isScanningMediaLibrary = false - if Task.isCancelled { mediaLibraryScanProgress.phase = "扫描已取消" } + 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 mediaLibraryStore.save(library: library) - mediaLibraryLiveLibraryID = library.id - mediaLibraryLiveItems = await mediaLibraryStore.items(libraryID: library.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() - mediaLibraryScanProgress = MediaLibraryScanProgress(phase: "正在发现媒体…") + 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 } @@ -445,25 +1805,48 @@ final class AppModel: ObservableObject { let key = file.gcid.flatMap { $0.isEmpty ? nil : "gcid:\($0.lowercased())" } ?? "file:\(file.id)" return seenFingerprints.insert(key).inserted } - guard !newFiles.isEmpty else { continue } - mediaLibraryScanProgress.total += newFiles.count - mediaLibraryScanProgress.phase = "\(library.kind.title)模式 · 边扫描边刮削…" - let batchItems = try await scrapeMediaFiles(newFiles, library: library, sidecars: allSidecars, progressOffset: items.count) - items += batchItems + 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() - mediaLibraryScanProgress.phase = "刮削完成" + 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() { @@ -477,7 +1860,7 @@ final class AppModel: ObservableObject { return copy } } - for _ in 0.. AsyncThrowingStream { - AsyncThrowingStream { continuation in + 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])] = library.sources.map { source in - (source.rootID, source.rootID == nil ? [] : source.path.split(separator: "/").map(String.init)) + 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: "/") } - let snapshot = self.mediaSnapshot(children: children, parentID: current.id) + // 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]) } } + if library.recursive { queue += children.filter(\.isDirectory).map { ($0.id, current.path + [$0.name], $0) } } } continuation.finish() } catch { @@ -515,8 +1914,19 @@ final class AppModel: ObservableObject { } } - private func mediaSnapshot(children: [CloudFile], parentID: String?) -> MediaScanSnapshot { - let directMedia = children.filter(\.isVideo) + 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 { @@ -539,38 +1949,64 @@ final class AppModel: ObservableObject { let subtitleStem = (lower as NSString).deletingPathExtension return subtitleStem == stem || subtitleStem.hasPrefix(stem + ".") || subtitleStem.hasPrefix(stem + "-") || subtitleStem.hasPrefix(stem + "_") } - sidecars[video.id] = MediaSidecarFiles(parentID: parentID, nfo: nfo, poster: poster, backdrop: backdrop, subtitles: subtitles) + // 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 func scrapeMediaFiles(_ files: [CloudFile], library: MediaLibraryDefinition, sidecars: [String: MediaSidecarFiles], progressOffset: Int) async throws -> [MediaLibraryItem] { + 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: parseMediaName(file.name)) + (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: (Int, MediaLibraryItem).self, returning: [(Int, MediaLibraryItem)].self) { group in + 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 (request.index, existing) - } - guard !apiKey.isEmpty else { return (request.index, fallback) } - let preferredKind: TMDBMediaKind - switch kind { - case .movies: preferredKind = .movie - case .series: preferredKind = .tv - case .mixed: preferredKind = request.parsed.isEpisode ? .tv : .automatic + 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) { @@ -581,13 +2017,21 @@ final class AppModel: ObservableObject { 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 (request.index, fallback) } + 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 response = try await api.tmdbSearch(query: request.parsed.title, apiKey: apiKey, mediaKind: preferredKind, proxyHost: proxyHost, proxyPort: proxyPort) - guard let searched = (response["results"]?.arrayValue ?? []).compactMap({ TMDBCandidate(json: $0, forcedKind: preferredKind == .automatic ? nil : preferredKind) }).first else { - return (request.index, fallback) + 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) @@ -613,30 +2057,163 @@ final class AppModel: ObservableObject { } else { backdropData = nil } - return (request.index, 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)) + 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 (request.index, fallback) + 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.0 < $1.0 }.map(\.1) + 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? { @@ -702,68 +2279,280 @@ final class AppModel: ObservableObject { return await sidecarData(for: file, api: api) } - private func enqueueMediaMetadataWriteback(_ item: MediaLibraryItem, files: MediaSidecarFiles) { + private func enqueueMediaMetadataWriteback(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID, organizeOnly: Bool = false) { guard item.isMatched else { return } - mediaMetadataWriteQueue.append((item, files)) - mediaMetadataWriteProgress.total += 1 - mediaMetadataWriteProgress.phase = "刮削入库并写回资源目录…" - guard mediaMetadataWriteTask == nil else { return } - isWritingMediaMetadata = true - mediaMetadataWriteTask = Task { [weak self] in + 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 } - while !self.mediaMetadataWriteQueue.isEmpty, !Task.isCancelled { - let (item, files) = self.mediaMetadataWriteQueue.removeFirst() - if !(await self.writeMediaScrapeItem(item, files: files)) { self.mediaMetadataWriteFailures += 1 } - self.mediaMetadataWriteProgress.completed += 1 + 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)") } - guard !Task.isCancelled else { self.isWritingMediaMetadata = false; self.mediaMetadataWriteTask = nil; return } - self.mediaMetadataWriteProgress.phase = self.mediaMetadataWriteFailures == 0 ? "资源目录写回完成" : "写回完成,\(self.mediaMetadataWriteFailures) 项失败" - self.isWritingMediaMetadata = false - self.mediaMetadataWriteTask = nil + 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) async -> Bool { - let parentKey = files.parentID ?? "root" + private func writeMediaScrapeItem(_ item: MediaLibraryItem, files: MediaSidecarFiles, taskID: UUID, organizeOnly: Bool = false) async -> Bool { do { - if files.nfo == nil { - let stem = (item.file.name as NSString).deletingPathExtension - let name = "\(stem).nfo" + 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.insert(key).inserted { - try await uploadScrapeData(Data(mediaNFO(for: item).utf8), name: name, contentType: "application/xml", parentID: files.parentID) + if mediaMetadataWrittenKeys[taskID, default: []].insert(key).inserted { + try await uploadScrapeData(Data(mediaNFO(for: item).utf8), name: name, contentType: "application/xml", parentID: preparedFiles.parentID) } } - if files.poster == nil, let data = item.posterData { + if preparedFiles.poster == nil, let data = item.posterData { let key = "\(parentKey)/poster.jpg" - if mediaMetadataWrittenKeys.insert(key).inserted { try await uploadScrapeData(data, name: "poster.jpg", contentType: "image/jpeg", parentID: files.parentID) } + if mediaMetadataWrittenKeys[taskID, default: []].insert(key).inserted { try await uploadScrapeData(data, name: "poster.jpg", contentType: "image/jpeg", parentID: preparedFiles.parentID) } } - if files.backdrop == nil, let data = item.backdropData { + if preparedFiles.backdrop == nil, let data = item.backdropData { let key = "\(parentKey)/fanart.jpg" - if mediaMetadataWrittenKeys.insert(key).inserted { try await uploadScrapeData(data, name: "fanart.jpg", contentType: "image/jpeg", parentID: files.parentID) } + 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 } } - func cancelMediaMetadataWriteback() { - mediaMetadataWriteTask?.cancel() - mediaMetadataWriteTask = nil - mediaMetadataWriteQueue.removeAll() - isWritingMediaMetadata = false - mediaMetadataWriteProgress.phase = "资源目录写回已停止" + 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 parsed = parseMediaName(item.file.name) - let root = parsed.isEpisode ? "episodedetails" : (item.mediaKind == .tv ? "tvshow" : "movie") + 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)" } ?? "" - let episode = parsed.isEpisode ? "\(parsed.season ?? 0)\(parsed.episode ?? 0)" : "" - return "<\(root)>\(uniqueID)\(item.title.xmlEscaped)\(item.originalTitle.xmlEscaped)\(item.overview.xmlEscaped)\(item.releaseDate.xmlEscaped)\(collection)\(episode)" + 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 { @@ -837,19 +2626,30 @@ final class AppModel: ObservableObject { 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 { self.isScanning = false } + defer { + if self.scanSessionID == sessionID { + self.isScanning = false + self.scanTask = nil + } + } do { - self.scanResult = try await self.runScan(request) + 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, @@ -860,13 +2660,32 @@ final class AppModel: ObservableObject { self.scanProgress.phase = "扫描已取消,已保留当前结果" } catch { + guard self.scanSessionID == sessionID else { return } self.scanProgress.phase = "扫描失败" self.errorMessage = error.localizedDescription } } } - func cancelScan() { scanTask?.cancel() } + 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() @@ -992,7 +2811,14 @@ final class AppModel: ObservableObject { groups.sort { $0.files.count > $1.files.count } return ScanResult(request: request, items: groups.map(ScanItem.duplicate), foldersScanned: scanProgress.foldersVisited, filesScanned: scanProgress.filesVisited, skippedFolders: 0) case .similarFolders: - let groups = Dictionary(grouping: snapshot.allFiles.filter(\.isDirectory), by: { normalizedFolderName($0.name) }).compactMap { key, folders -> SimilarFolderGroup? in folders.count > 1 ? SimilarFolderGroup(id: key, folders: folders) : nil } + 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) } } @@ -1004,6 +2830,8 @@ final class AppModel: ObservableObject { 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, @@ -1040,6 +2868,34 @@ final class AppModel: ObservableObject { 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. @@ -1061,6 +2917,191 @@ final class AppModel: ObservableObject { 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 @@ -1277,6 +3318,12 @@ final class AppModel: ObservableObject { 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) @@ -1503,20 +3550,99 @@ final class AppModel: ObservableObject { 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 stem = (name as NSString).deletingPathExtension - let normalized = stem.replacingOccurrences(of: "[._]+", with: " ", options: .regularExpression) + 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 year = yearRegex?.firstMatch(in: normalized, range: NSRange(normalized.startIndex..., in: normalized)).flatMap { Int((normalized as NSString).substring(with: $0.range(at: 1))) } - var title = normalized - title = title.replacingOccurrences(of: "(?i)\\bS\\d{1,2}[ ._-]*E\\d{1,3}\\b|\\b\\d{1,2}x\\d{1,3}\\b|第\\s*\\d{1,2}\\s*季\\s*第?\\s*\\d{1,3}\\s*[集话]|\\b\\d{1,2}[ ._-]\\d{1,3}\\b", with: " ", options: .regularExpression) - title = title.replacingOccurrences(of: "(?i)\\b(19\\d{2}|20\\d{2}|2160p|1080p|720p|480p|web[- ]?dl|webrip|bluray|brrip|remux|x26[45]|h\\.?26[45]|hevc|av1|aac|dts|atmos|hdr10?\\+?|dv|proper|repack|extended|complete|season|中字|简体|繁体|chs|cht|eng)\\b", with: " ", options: .regularExpression) + 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 } @@ -1527,9 +3653,52 @@ final class AppModel: ObservableObject { 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 { @@ -1541,7 +3710,20 @@ final class AppModel: ObservableObject { private func cleanedTMDBQuery(_ name: String) -> String { parseMediaName(name).title } private func normalizedFolderName(_ value: String) -> String { - value.lowercased().replacingOccurrences(of: "[\\s._()\\[\\]{}-]+", with: "", options: .regularExpression) + 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) { @@ -1661,15 +3843,7 @@ final class AppModel: ObservableObject { } private nonisolated func extractFiles(from value: JSONValue) -> [CloudFile] { - var result: [CloudFile] = [] - var seen = Set() - func visit(_ value: JSONValue) { - if let file = CloudFile(json: value), seen.insert(file.id).inserted { result.append(file) } - if let object = value.objectValue { object.values.forEach(visit) } - if let array = value.arrayValue { array.forEach(visit) } - } - visit(value) - return result + extractCloudFiles(from: value) } private func startQRPolling(expiresIn: Int, interval: Int) { diff --git a/guangya_mac/ContentView.swift b/guangya_mac/ContentView.swift index e28c64e..25ccc91 100644 --- a/guangya_mac/ContentView.swift +++ b/guangya_mac/ContentView.swift @@ -1,4 +1,5 @@ import AppKit +import AVFoundation import AVKit import CoreImage.CIFilterBuiltins import SwiftUI @@ -7,6 +8,9 @@ import WebKit struct ContentView: View { @StateObject private var model = AppModel() + #if DEBUG + @State private var injectionRevision = 0 + #endif var body: some View { Group { @@ -21,6 +25,16 @@ struct ContentView: View { } message: { Text(model.errorMessage) } + #if DEBUG + // InjectionIII posts this after replacing an implementation. Recreating the view + // hierarchy makes SwiftUI evaluate the newly injected view bodies immediately. + .id(injectionRevision) + .onReceive(NotificationCenter.default.publisher(for: Notification.Name("INJECTION_BUNDLE_NOTIFICATION"))) { _ in + DispatchQueue.main.async { + injectionRevision &+= 1 + } + } + #endif } } @@ -313,12 +327,16 @@ private struct LoginFieldLabel: View { struct WorkspaceSidebar: View { @ObservedObject var model: AppModel @Binding var selectedTool: WorkspaceTool? - @Binding var columnVisibility: NavigationSplitViewVisibility - @State private var toolsExpanded = true - @State private var categoriesExpanded = false - @State private var mediaExpanded = true + let isCompact: Bool var body: some View { + Group { + if isCompact { compactSidebar } + else { expandedSidebar } + } + } + + private var expandedSidebar: some View { VStack(alignment: .leading, spacing: 18) { HStack(spacing: 12) { BrandMark(size: 34) @@ -333,84 +351,32 @@ struct WorkspaceSidebar: View { .padding(.top, 8) ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: 18) { - VStack(spacing: 5) { - Button { - withAnimation(.easeInOut(duration: 0.18)) { mediaExpanded = true } - model.mediaLibraryTarget = nil - model.mediaLibraryDestination = .all - selectedTool = .media - } label: { - HStack(spacing: 10) { - Image(systemName: "play.tv.fill").foregroundStyle(AppTheme.orange).frame(width: 26) - Text("影视库").font(.callout.weight(.semibold)) - Spacer() - } - .padding(10).background(selectedTool == .media && model.mediaLibraryDestination == .all ? AppTheme.orange.opacity(0.14) : Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - if mediaExpanded { - VStack(spacing: 3) { - MediaLibrarySidebarRow(title: "所有影视", icon: "rectangle.grid.2x2", isSelected: selectedTool == .media && model.mediaLibraryDestination == .all) { - model.mediaLibraryDestination = .all; selectedTool = .media - } - ForEach(model.mediaLibraries) { library in - MediaLibrarySidebarRow(title: library.name, icon: library.kind.icon, isSelected: selectedTool == .media && model.mediaLibraryDestination == .library(library.id)) { - model.mediaLibraryDestination = .library(library.id); selectedTool = .media - } - } - MediaLibrarySidebarRow(title: "刮削任务", icon: model.isScanningMediaLibrary || model.isWritingMediaMetadata ? "arrow.triangle.2.circlepath" : "checklist", isSelected: selectedTool == .media && model.mediaLibraryDestination == .tasks, status: model.isScanningMediaLibrary || model.isWritingMediaMetadata ? "进行中" : nil) { - model.mediaLibraryDestination = .tasks; selectedTool = .media - } - } - .padding(.leading, 18) - } - } - VStack(alignment: .leading, spacing: 8) { - Text("文件空间").font(.caption.weight(.bold)).foregroundStyle(.secondary).padding(.horizontal, 8) + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 5) { ForEach([WorkspaceSection.files, .recentViewed, .recentRestored]) { section in SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } } } - VStack(alignment: .leading, spacing: 8) { - Text("工作区").font(.caption.weight(.bold)).foregroundStyle(.secondary).padding(.horizontal, 8) + Divider().padding(.horizontal, 8) + VStack(alignment: .leading, spacing: 5) { ForEach([WorkspaceSection.cloud, .shares, .recycle]) { section in SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } } } - - VStack(spacing: 5) { - Button { withAnimation(.easeInOut(duration: 0.18)) { categoriesExpanded.toggle() } } label: { HStack { Image(systemName: "square.grid.2x2").foregroundStyle(.orange).frame(width: 26); Text("文件分类").font(.callout.weight(.semibold)); Spacer(); Image(systemName: "chevron.right").font(.caption.weight(.bold)).rotationEffect(.degrees(categoriesExpanded ? 90 : 0)) }.frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()).padding(10).background(Color.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 13)) }.buttonStyle(.plain) - if categoriesExpanded { VStack(spacing: 4) { ForEach([WorkspaceSection.photos, .videos, .audio, .documents]) { section in SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } } }.transition(.opacity.combined(with: .move(edge: .top))) } - } - - VStack(spacing: 6) { - Button { withAnimation(.easeInOut(duration: 0.18)) { toolsExpanded.toggle() } } label: { - HStack(spacing: 10) { - Image(systemName: "shippingbox.fill").foregroundStyle(.orange).frame(width: 26) - VStack(alignment: .leading, spacing: 2) { - Text("工具箱").font(.callout.weight(.semibold)) - Text("独立工具页面").font(.caption2).foregroundStyle(.secondary) - } - Spacer() - Image(systemName: "chevron.down").font(.caption.weight(.bold)).foregroundStyle(.tertiary).rotationEffect(.degrees(toolsExpanded ? 0 : -90)) - } - .padding(12) - .background(Color.orange.opacity(0.10), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) + Divider().padding(.horizontal, 8) + VStack(alignment: .leading, spacing: 5) { + ForEach([WorkspaceSection.photos, .videos, .audio, .documents]) { section in + SidebarItem(section: section, selected: selectedTool == nil && model.section == section) { selectedTool = nil; model.section = section } } - .buttonStyle(.plain) - - if toolsExpanded { - VStack(spacing: 4) { - ForEach(WorkspaceTool.allCases.filter { $0 != .media }) { tool in - ToolSidebarItem(tool: tool, selected: selectedTool == tool) { - if tool == .rename { - model.batchRenameTarget = nil - model.batchRenameSelection = [] - } - selectedTool = tool - } + } + Divider().padding(.horizontal, 8) + VStack(alignment: .leading, spacing: 5) { + ForEach(WorkspaceTool.allCases.filter { $0 != .media && $0 != .tmdb }) { tool in + ToolSidebarItem(tool: tool, selected: selectedTool == tool) { + if tool == .rename { + model.batchRenameTarget = nil + model.batchRenameSelection = [] } + selectedTool = tool } - .transition(.opacity.combined(with: .move(edge: .top))) } } } @@ -419,12 +385,6 @@ struct WorkspaceSidebar: View { .frame(maxHeight: .infinity) .clipped() - HStack { - Spacer() - Button { withAnimation(.easeInOut(duration: 0.2)) { columnVisibility = columnVisibility == .detailOnly ? .all : .detailOnly } } label: { Image(systemName: "sidebar.left") } - .buttonStyle(.bordered).controlSize(.small).help("显示或隐藏侧边栏") - } - HStack(spacing: 11) { Menu { Text("会员:\(model.user.memberLevel)") @@ -446,10 +406,64 @@ struct WorkspaceSidebar: View { .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } .padding(16) - .softCard(radius: 24) - .padding(10) .task { await model.loadMediaLibraries() } } + + private var compactSidebar: some View { + VStack(spacing: 10) { + BrandMark(size: 26) + .padding(7).background(Color.orange.opacity(0.14), in: RoundedRectangle(cornerRadius: 9)) + .help("光鸭云盘") + Divider() + compactSectionButton(.files) + compactSectionButton(.recentViewed) + compactSectionButton(.recentRestored) + Divider().padding(.vertical, 2) + compactSectionButton(.cloud) + compactSectionButton(.shares) + compactSectionButton(.recycle) + Divider().padding(.vertical, 2) + ForEach([WorkspaceSection.photos, .videos, .audio, .documents]) { compactSectionButton($0) } + Divider().padding(.vertical, 2) + ForEach(WorkspaceTool.allCases.filter { $0 != .media && $0 != .tmdb }) { tool in + compactToolButton(tool) + } + Spacer(minLength: 6) + Menu { + Text(model.user.name) + if !model.user.phone.isEmpty { Text(model.user.phone) } + Divider() + Button("退出登录", role: .destructive) { model.logout() } + } label: { + Text(String(model.user.name.prefix(1))).font(.caption.weight(.bold)).foregroundStyle(.orange) + .frame(width: 32, height: 32).background(Color.orange.opacity(0.16), in: Circle()) + } + .menuStyle(.borderlessButton).help("账户:\(model.user.name)") + } + .padding(.vertical, 14).frame(width: 64).frame(maxHeight: .infinity) + .background(.regularMaterial) + .task { await model.loadMediaLibraries() } + } + + private func compactSectionButton(_ section: WorkspaceSection) -> some View { + let isSelected = selectedTool == nil && model.section == section + return Button { selectedTool = nil; model.section = section } label: { + Image(systemName: section.icon).frame(width: 34, height: 34) + .foregroundStyle(isSelected ? Color.orange : Color.secondary) + .background(isSelected ? Color.orange.opacity(0.16) : .clear, in: RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(.plain).help(section.rawValue).compactHoverHint(section.rawValue).accessibilityLabel(section.rawValue) + } + + private func compactToolButton(_ tool: WorkspaceTool) -> some View { + let isSelected = selectedTool == tool + return Button { selectedTool = tool } label: { + Image(systemName: tool.icon).frame(width: 34, height: 34) + .foregroundStyle(isSelected ? Color.orange : Color.secondary) + .background(isSelected ? Color.orange.opacity(0.16) : .clear, in: RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(.plain).help(tool.rawValue).compactHoverHint(tool.rawValue).accessibilityLabel(tool.rawValue) + } } private struct MediaLibrarySidebarRow: View { @@ -537,23 +551,26 @@ struct FilesBrowser: View { @Binding var isImporting: Bool @Binding var isShowingSettings: Bool @Binding var isShowingDuplicates: Bool + @Binding var isDualPanel: Bool let onPreview: (CloudFile) -> Void let onOpenTool: (WorkspaceTool) -> Void let onShowInspector: () -> Void - @State private var pendingDelete: CloudFile? + var dualPanelContent: AnyView? = nil // Optional dual-panel view to replace file list @State private var showBatchDeleteConfirmation = false @State private var selectionAnchorID: String? @State private var keyboardFocusID: String? @State private var tmdbTarget: CloudFile? - // Server already returns this page in requested order; keep client filtering order intact. + // The title-bar search is a separate whole-drive result set; normal browsing + // keeps the server's current-folder order intact. private var filteredFiles: [CloudFile] { - globalSearchText.isEmpty ? model.files : model.files.filter { $0.name.localizedCaseInsensitiveContains(globalSearchText) } + globalSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? model.files : model.globalFileSearchResults } - private var fileCount: Int { model.files.filter { !$0.isDirectory }.count } - private var folderCount: Int { model.files.filter(\.isDirectory).count } + private var isShowingGlobalSearchResults: Bool { !globalSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private var fileCount: Int { filteredFiles.filter { !$0.isDirectory }.count } + private var folderCount: Int { filteredFiles.filter(\.isDirectory).count } private var totalSizeText: String { - let total = model.files.compactMap(\.size).reduce(Int64(0), +) + let total = filteredFiles.compactMap(\.size).reduce(Int64(0), +) guard total > 0 else { return "--" } let formatter = ByteCountFormatter(); formatter.countStyle = .file return formatter.string(fromByteCount: total) @@ -572,7 +589,6 @@ struct FilesBrowser: View { .onCommand(#selector(NSResponder.scrollPageDown(_:))) { moveKeyboardSelection(page: 1) } .onCommand(#selector(NSResponder.moveToBeginningOfDocument(_:))) { selectKeyboardIndex(0) } .onCommand(#selector(NSResponder.moveToEndOfDocument(_:))) { selectKeyboardIndex(max(0, filteredFiles.count - 1)) } - .onCommand(#selector(NSResponder.deleteBackward(_:))) { if !selectedFileIDs.isEmpty { showBatchDeleteConfirmation = true } } .sheet(item: $tmdbTarget) { TMDBRecognizeSheet(model: model, file: $0) } .confirmationDialog("确认删除已选 \(selectedFileIDs.count) 项?", isPresented: $showBatchDeleteConfirmation) { Button("删除 \(selectedFileIDs.count) 项", role: .destructive) { @@ -583,28 +599,26 @@ struct FilesBrowser: View { } Button("取消", role: .cancel) { } } message: { Text("选中的文件及文件夹将被删除。文件夹会连同其内容一起删除,且此操作不可撤销。") } - .confirmationDialog( - pendingDelete?.isDirectory == true ? "确认删除文件夹?" : "确认删除文件?", - isPresented: Binding( - get: { pendingDelete != nil }, - set: { if !$0 { pendingDelete = nil } } - ), - presenting: pendingDelete - ) { file in - Button("删除", role: .destructive) { - pendingDelete = nil - selectedFileIDs.remove(file.id) - Task { await model.delete(file) } - } - Button("取消", role: .cancel) { pendingDelete = nil } - } message: { file in - Text(file.isDirectory ? "“\(file.name)”及其包含的内容将被删除。删除成功后会直接从当前列表移除。" : "“\(file.name)”将被删除。删除成功后会直接从当前列表移除。") - } } private var browserHeader: some View { HStack(spacing: 12) { - breadcrumb + if isShowingGlobalSearchResults { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.orange) + Text("搜索结果:\(globalSearchText)") + .font(.callout.weight(.semibold)) + .lineLimit(1) + Button { clearGlobalSearchResults() } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("返回全部文件") + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + breadcrumb + } Spacer(minLength: 12) browserActions } @@ -642,9 +656,9 @@ struct FilesBrowser: View { private var statusBar: some View { HStack(spacing: 12) { - Label("本页文件 \(fileCount)", systemImage: "doc.fill").foregroundStyle(.secondary) - Label("本页文件夹 \(folderCount)", systemImage: "folder.fill").foregroundStyle(.secondary) - Label("本页大小 \(totalSizeText)", systemImage: "externaldrive.fill").foregroundStyle(.secondary) + Label("\(globalSearchText.isEmpty ? "本页" : "搜索结果")文件 \(fileCount)", systemImage: "doc.fill").foregroundStyle(.secondary) + Label("\(globalSearchText.isEmpty ? "本页" : "搜索结果")文件夹 \(folderCount)", systemImage: "folder.fill").foregroundStyle(.secondary) + Label("\(globalSearchText.isEmpty ? "本页" : "搜索结果")大小 \(totalSizeText)", systemImage: "externaldrive.fill").foregroundStyle(.secondary) if model.isLoadingFiles || model.isLoadingFolderSizes || model.isBusy { HStack(spacing: 6) { ProgressView().controlSize(.small); Text(model.statusMessage.isEmpty ? "正在同步…" : model.statusMessage).font(.caption).foregroundStyle(.secondary) } } Spacer(minLength: 4) paginationControls @@ -662,11 +676,16 @@ struct FilesBrowser: View { Button(role: .destructive) { showBatchDeleteConfirmation = true } label: { Image(systemName: "trash") } .buttonStyle(.bordered).help("删除选中的 \(selectedFileIDs.count) 项") } - if model.section == .files { + if model.section == .files && !isShowingGlobalSearchResults { Button { isImporting = true } label: { Label("上传", systemImage: "arrow.up") } .buttonStyle(.borderedProminent).tint(.orange) Button { isCreatingFolder = true } label: { Image(systemName: "folder.badge.plus") } .buttonStyle(.bordered).help("新建文件夹") + Button { withAnimation(.easeInOut(duration: 0.2)) { isDualPanel.toggle() } } label: { + Image(systemName: isDualPanel ? "rectangle.split.2x1" : "rectangle.on.rectangle") + } + .buttonStyle(.bordered) + .help(isDualPanel ? "切换到单面板" : "左右双面板浏览") } Button { Task { await model.refresh() } } label: { if model.isLoadingFiles { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.clockwise") } @@ -682,8 +701,10 @@ struct FilesBrowser: View { private var content: some View { Group { - if model.section == .cloud || model.section == .shares { EmptySectionView(section: model.section).softCard(radius: 24).padding(.horizontal, 10) } - else if model.isLoadingFiles { LoadingFilesView().softCard(radius: 8).padding(.horizontal, 10) } + if let dualContent = dualPanelContent { + dualContent + } else if model.section == .cloud || model.section == .shares { EmptySectionView(section: model.section).softCard(radius: 24).padding(.horizontal, 10) } + else if model.isLoadingFiles || model.isGlobalFileSearching { LoadingFilesView().softCard(radius: 8).padding(.horizontal, 10) } else if filteredFiles.isEmpty { EmptyFilesView(model: model).softCard(radius: 24).padding(.horizontal, 10) } else { List(selection: $selectedFileIDs) { @@ -721,6 +742,15 @@ struct FilesBrowser: View { .listStyle(.plain) .scrollContentBackground(.hidden) .softCard(radius: 24) + .onDeleteCommand { + guard !selectedFileIDs.isEmpty else { return } + showBatchDeleteConfirmation = true + } + .onKeyPress(.delete, phases: .down) { press in + guard press.modifiers.contains(.command), !selectedFileIDs.isEmpty else { return .ignored } + showBatchDeleteConfirmation = true + return .handled + } } } } @@ -729,6 +759,13 @@ struct FilesBrowser: View { moveKeyboardSelection(page: direction == .up || direction == .left ? -1 : 1) } + private func clearGlobalSearchResults() { + globalSearchText = "" + model.clearGlobalFileSearch() + selectedFileIDs.removeAll() + selectionAnchorID = nil + } + private func moveKeyboardSelection(page: Int) { guard !filteredFiles.isEmpty else { return } let step = abs(page) == 1 ? 1 : 12 @@ -773,9 +810,9 @@ struct FilesBrowser: View { private var paginationControls: some View { HStack(spacing: 10) { Picker("每页", selection: Binding(get: { model.pageSize }, set: { size in Task { await model.setPageSize(size) } })) { - Text("50 / 页").tag(50); Text("100 / 页").tag(100); Text("200 / 页").tag(200); Text("500 / 页").tag(500); Text("1000 / 页").tag(1000) + Text("50 / 页").tag(50); Text("100 / 页").tag(100); Text("200 / 页").tag(200); Text("500 / 页").tag(500); Text("1000 / 页").tag(1000); Text("5000 / 页").tag(5000); Text("10000 / 页").tag(10000) } - .labelsHidden().frame(width: 92) + .labelsHidden().frame(width: 108) Button { Task { await model.goToPage(model.currentPage - 1) } } label: { Image(systemName: "chevron.left") } .buttonStyle(.bordered).disabled(model.currentPage == 0 || model.isLoadingFiles) Text("\(model.currentPage + 1) / \(model.totalPages)").font(.callout.monospacedDigit()).frame(minWidth: 62) @@ -803,7 +840,23 @@ struct FilesBrowser: View { @ViewBuilder private func fileMenu(_ file: CloudFile) -> some View { Button { if file.isDirectory { Task { await model.openFolder(file) } } else { onPreview(file) } } label: { Label(file.isDirectory ? "打开文件夹" : "预览", systemImage: file.isDirectory ? "folder" : "eye") } if !file.isDirectory { Button { Task { await model.download(file) } } label: { Label("下载", systemImage: "arrow.down.circle") } } - if file.isVideo { Button { Task { await model.playWithExternalPlayer(file) } } label: { Label("使用 IINA / VLC 播放", systemImage: "play.rectangle.on.rectangle") } } + if file.isVideo { + Menu("播放") { + if AppModel.installedPlayers.isEmpty { + Button { Task { await model.playWithExternalPlayer(file) } } label: { Label("系统默认播放器", systemImage: "play.fill") } + } else { + ForEach(AppModel.installedPlayers, id: \.name) { player in + Button { Task { await model.openFile(file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") } + } + } + } + } else if !file.isDirectory && !AppModel.installedPlayers.isEmpty { + Menu("用外部播放器打开") { + ForEach(AppModel.installedPlayers, id: \.name) { player in + Button { Task { await model.openFile(file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") } + } + } + } Button { selectedFileIDs = [file.id] onShowInspector() @@ -821,27 +874,28 @@ struct FilesBrowser: View { Button { renameFile = file } label: { Label("重命名", systemImage: "pencil") } if file.isDirectory { Menu("文件夹工具") { - Button { - model.mediaLibraryTarget = file - onOpenTool(.media) - } label: { Label("打开为影视库", systemImage: "play.tv.fill") } Button { openBatchRenameTool(for: file) } label: { Label("批量重命名…", systemImage: "character.cursor.ibeam") } Button { openScanTool(.emptyFolders, for: file) } label: { Label("扫描空文件夹", systemImage: "folder.badge.minus") } Button { openScanTool(.duplicates, for: file) } label: { Label("扫描重复文件", systemImage: "square.on.square") } Button { openScanTool(.similarFolders, for: file) } label: { Label("扫描相似文件夹", systemImage: "rectangle.3.group") } } - Button { - model.tmdbTarget = file - onOpenTool(.tmdb) - } label: { Label("TMDB 识别与刮削…", systemImage: "film") } } else { Button { openBatchRenameTool(for: file) } label: { Label("批量重命名…", systemImage: "character.cursor.ibeam") } - Button { tmdbTarget = file } label: { Label("TMDB 识别与刮削…", systemImage: "film") } } Button { model.copyTransferJSON(for: file) } label: { Label("复制秒传 JSON", systemImage: "doc.on.doc") } Divider() if model.section == .recycle { Button { Task { await model.restore(file) } } label: { Label("恢复", systemImage: "arrow.uturn.backward") } } - Button(role: .destructive) { pendingDelete = file } label: { Label("删除…", systemImage: "trash") } + Button(role: .destructive) { requestDelete(file) } label: { Label("删除…", systemImage: "trash") } + } + + private func requestDelete(_ file: CloudFile) { + // Right-clicking an already-selected row must preserve the whole selection. + // A non-selected row becomes the explicit one-item deletion target. + if !selectedFileIDs.contains(file.id) { + selectedFileIDs = [file.id] + selectionAnchorID = file.id + } + showBatchDeleteConfirmation = true } private func openScanTool(_ kind: ScanKind, for folder: CloudFile) { @@ -1021,68 +1075,241 @@ private struct FileDetailsSheet: View { struct FilePreviewSheet: View { @ObservedObject var model: AppModel let file: CloudFile + var onClose: (() -> Void)? = nil @Environment(\.dismiss) private var dismiss @State private var url: URL? @State private var errorText = "" private var ext: String { (file.name as NSString).pathExtension.lowercased() } private var isImage: Bool { file.fileType == 1 || ["jpg", "jpeg", "png", "gif", "webp", "heic", "bmp", "tiff"].contains(ext) } - // AVPlayer handles the native family; containers/codecs beyond it can be handed - // to IINA/VLC, which are purpose-built for MKV, HEVC, AV1, DTS, subtitles, etc. private var isVideo: Bool { file.isVideo } - private var nativeVideo: Bool { ["mp4", "mov", "m4v"].contains(ext) } var body: some View { - VStack(spacing: 0) { - HStack { Image(systemName: file.icon).foregroundStyle(.orange); Text(file.name).font(.headline).lineLimit(1); Spacer(); if isVideo { Button { Task { await model.playWithExternalPlayer(file) } } label: { Label("使用 IINA / VLC", systemImage: "play.rectangle.on.rectangle") }.buttonStyle(.bordered).help("适用于 MKV、AV1、HEVC、DTS、外挂字幕等格式") }; Button("关闭 (Esc)") { dismiss() }.buttonStyle(.bordered) } - .padding(14).background(.thinMaterial) - Divider() - ZStack { - Color.black.opacity(0.92) - if let url { - if isImage { - AsyncImage(url: url) { phase in - switch phase { - case .success(let image): image.resizable().scaledToFit().padding(24) - case .failure: previewFailure("图片加载失败", icon: "photo.badge.exclamationmark") - default: ProgressView("正在加载图片…").tint(.white).foregroundStyle(.white) - } + ZStack { + Color.black.opacity(0.92) + if let url { + if isImage { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): image.resizable().scaledToFit().padding(24) + case .failure: previewFailure("图片加载失败", icon: "photo.badge.exclamationmark") + default: ProgressView("正在加载图片…").tint(.white).foregroundStyle(.white) } - } else if isVideo { - if nativeVideo { VideoPlayer(player: AVPlayer(url: url)).padding(12) } - else { externalPlayerPrompt } - } else { - WebPreview(url: url) } - } else if !errorText.isEmpty { - previewFailure(errorText, icon: "eye.slash") } else { - ProgressView("正在获取预览地址…").tint(.white).foregroundStyle(.white) + WebPreview(url: url) } + } else if !errorText.isEmpty { + previewFailure(errorText, icon: "eye.slash") + } else { + ProgressView("正在获取预览地址…").tint(.white).foregroundStyle(.white) } - .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(minWidth: 900, minHeight: 650) - .onExitCommand { dismiss() } - .task { do { url = try await model.remoteURL(for: file) } catch { errorText = error.localizedDescription } } + .navigationTitle(file.name) + .onExitCommand { closePreview() } + .task { await loadPreviewURL() } } - private var externalPlayerPrompt: some View { - VStack(spacing: 16) { - Image(systemName: "play.rectangle.on.rectangle").font(.system(size: 44)).foregroundStyle(.white) - Text("建议使用外部播放器").font(.title3.weight(.semibold)).foregroundStyle(.white) - Text("该格式可能包含 AVPlayer 不支持的编码、音轨或字幕。已优先支持 IINA,其次 VLC。") - .font(.callout).foregroundStyle(.white.opacity(0.72)).multilineTextAlignment(.center) - Button { Task { await model.playWithExternalPlayer(file); dismiss() } } label: { Label("使用 IINA / VLC 播放", systemImage: "play.fill") } - .buttonStyle(.borderedProminent) - }.padding(32) + private func loadPreviewURL() async { + do { + let fetchedURL = try await model.remoteURL(for: file) + url = fetchedURL + } catch { + errorText = error.localizedDescription + } } @ViewBuilder private func previewFailure(_ text: String, icon: String) -> some View { ContentUnavailableView(text, systemImage: icon).foregroundStyle(.white) } + + private func closePreview() { + if let onClose { onClose() } + else { dismiss() } + } } +/// Non-modal AppKit player window. Unlike a SwiftUI sheet, it never blocks the +/// cloud-drive window, so playback can stay open while files are managed. +@MainActor +final class PlayerWindowCoordinator: NSObject, NSWindowDelegate { + static let shared = PlayerWindowCoordinator() + private var windows: [String: NSWindow] = [:] + + func open(model: AppModel, file: CloudFile) { + if file.isVideo { + Task { await openWithExternalPlayer(model: model, file: file) } + return + } + if let window = windows[file.id] { + window.makeKeyAndOrderFront(nil) + return + } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1080, height: 720), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = file.name + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isReleasedWhenClosed = false + window.minSize = NSSize(width: 760, height: 500) + window.delegate = self + window.standardWindowButton(.closeButton)?.isHidden = false + window.standardWindowButton(.miniaturizeButton)?.isHidden = false + window.standardWindowButton(.zoomButton)?.isHidden = false + window.contentViewController = NSHostingController(rootView: FilePreviewSheet(model: model, file: file, onClose: { [weak self] in + self?.close(fileID: file.id) + })) + windows[file.id] = window + window.center() + window.makeKeyAndOrderFront(nil) + } + + private func openWithExternalPlayer(model: AppModel, file: CloudFile) async { + let installed = AppModel.installedPlayers + if installed.count == 1 { + await model.openFile(file, withPlayerNamed: installed[0].name) + } else if installed.count > 1 { + await MainActor.run { + showPlayerPicker(model: model, file: file, installed: installed) + } + } else { + await model.playWithExternalPlayer(file) + } + } + + private func showPlayerPicker(model: AppModel, file: CloudFile, installed: [(name: String, bundleID: String)]) { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 380, height: 0), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + panel.title = "选择播放器" + panel.isReleasedWhenClosed = false + panel.level = .floating + panel.isFloatingPanel = true + let hostingView = NSHostingView(rootView: PlayerPickerPanel(model: model, file: file, installed: installed, onDismiss: { panel.close() })) + panel.contentView = hostingView + panel.setContentSize(hostingView.fittingSize) + panel.center() + panel.makeKeyAndOrderFront(nil) + } + + func close(fileID: String) { + guard let window = windows[fileID] else { return } + windows[fileID] = nil + window.close() + } + + nonisolated func windowWillClose(_ notification: Notification) { + guard let window = notification.object as? NSWindow else { return } + Task { @MainActor [weak self] in + self?.windows = self?.windows.filter { $0.value !== window } ?? [:] + } + } +} + + +/// Floating panel for choosing among installed external players. +private struct PlayerPickerPanel: View { + @ObservedObject var model: AppModel + let file: CloudFile + let installed: [(name: String, bundleID: String)] + let onDismiss: () -> Void + + private let columns = [ + GridItem(.adaptive(minimum: 80, maximum: 100), spacing: 0) + ] + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 6) { + Image(systemName: "play.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(.orange) + Text("选择播放器") + .font(.title3.weight(.semibold)) + Text(file.name) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 20) + + Divider() + + LazyVGrid(columns: columns, spacing: 4) { + ForEach(installed, id: \.name) { player in + PlayerCard(player: player, file: file, model: model, onDismiss: onDismiss) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + } +} + +private struct PlayerCard: View { + let player: (name: String, bundleID: String) + let file: CloudFile + @ObservedObject var model: AppModel + let onDismiss: () -> Void + @State private var isHovering = false + @State private var iconImage: NSImage? + + var body: some View { + Button { + Task { await model.openFile(file, withPlayerNamed: player.name) } + onDismiss() + } label: { + VStack(spacing: 6) { + Group { + if let img = iconImage { + Image(nsImage: img) + .resizable() + } else { + Image(systemName: "play.rectangle.fill") + .font(.system(size: 28)) + .foregroundStyle(.orange) + } + } + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + Text(player.name) + .font(.caption2.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + } + .frame(width: 84, height: 76) + .background(isHovering ? Color(nsColor: .controlAccentColor).opacity(0.12) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(isHovering ? Color.accentColor.opacity(0.5) : Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .onAppear { loadIcon() } + } + + private func loadIcon() { + guard iconImage == nil else { return } + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: player.bundleID) else { return } + iconImage = NSWorkspace.shared.icon(forFile: url.path) + } +} + + private struct WebPreview: NSViewRepresentable { let url: URL func makeNSView(context: Context) -> WKWebView { let view = WKWebView(); view.setValue(false, forKey: "drawsBackground"); return view } @@ -1196,6 +1423,10 @@ struct ToolWorkspacePage: View { var body: some View { if tool == .media { MediaLibraryPage(model: model) + } else if tool == .fastTransfer { + FastTransferToolPage(model: model) + .padding(14) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { VStack(alignment: .leading, spacing: 18) { HStack(spacing: 14) { @@ -1209,7 +1440,8 @@ struct ToolWorkspacePage: View { case .media: EmptyView() case .scan: UnifiedScanToolPage(model: model) case .rename: BatchRenameToolPage(model: model) - case .tmdb: TMDBToolPage(model: model) + case .fastTransfer: EmptyView() + case .tmdb: EmptyView() case .settings: ToolSettingsPage(model: model) } } @@ -1220,17 +1452,862 @@ struct ToolWorkspacePage: View { } } +private enum FastTransferToolMode: String, CaseIterable, Identifiable { + case importJSON + case generateJSON + + var id: String { rawValue } + var title: String { self == .importJSON ? "JSON 秒传" : "本地生成 JSON" } +} + +private enum FastTransferImportPhase { + case chooseSource + case parsing + case ready +} + +private enum FastTransferQueueSection: String, CaseIterable, Identifiable { + case pending + case issues + case imported + case skipped + + var id: String { rawValue } + + var title: String { + switch self { + case .pending: return "主队列" + case .issues: return "出错" + case .imported: return "成功" + case .skipped: return "已跳过" + } + } +} + +private struct FastTransferQueueSnapshot { + typealias Task = (entry: FastTransferEntry, result: FastTransferResult?) + + var latestResults: [UUID: FastTransferResult] = [:] + var retriedResultIDs: Set = [] + var entryByID: [UUID: FastTransferEntry] = [:] + var pending: [Task] = [] + var issues: [Task] = [] + var imported: [Task] = [] + var skipped: [Task] = [] + + init(entries: [FastTransferEntry] = [], results: [FastTransferResult] = []) { + latestResults = results.reduce(into: [:]) { $0[$1.entry.id] = $1 } + retriedResultIDs = Set(results.compactMap(\.retryOf)) + entryByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) + pending.reserveCapacity(entries.count) + for entry in entries { + let task = (entry: entry, result: latestResults[entry.id]) + guard let result = task.result else { + pending.append(task) + continue + } + switch result.state { + case .imported: imported.append(task) + case .skipped: skipped.append(task) + case .failed, .cancelled: issues.append(task) + } + } + } + + func tasks(in section: FastTransferQueueSection) -> [Task] { + switch section { + case .pending: return pending + case .issues: return issues + case .imported: return imported + case .skipped: return skipped + } + } +} + +private struct FastTransferToolPage: View { + @ObservedObject var model: AppModel + @State private var mode: FastTransferToolMode = .importJSON + @State private var jsonText = "" + @State private var importPhase: FastTransferImportPhase = .chooseSource + @State private var localURLs: [URL] = [] + @State private var generatedEntries: [FastTransferEntry] = [] + @State private var generatedJSON = "" + @State private var targetID: String? + @State private var targetName = "当前文件夹" + @AppStorage("guangya.fastTransfer.createDirectories") private var createDirectories = true + @AppStorage("guangya.fastTransfer.skipExisting") private var skipExisting = true + @State private var showTargetPicker = false + @State private var showLocalImporter = false + @State private var localError = "" + @State private var importTask: Task? + @State private var queuePage = 0 + @State private var queuePageSize = 200 + @State private var queueSection: FastTransferQueueSection = .pending + @State private var queueSnapshot = FastTransferQueueSnapshot() + + var body: some View { + Group { + if mode == .importJSON && importPhase != .ready { + importSourceView + } else { + VStack(spacing: 0) { + Picker("功能", selection: $mode) { + ForEach(FastTransferToolMode.allCases) { option in + Text(option.title).tag(option) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 22).padding(.vertical, 16) + Divider() + Group { + switch mode { + case .importJSON: importView + case .generateJSON: generateView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .sheet(isPresented: $showTargetPicker) { + ScanFolderPickerSheet(model: model, title: "选择秒传目标目录", selectionLabel: "使用此目录") { id, path in + saveTargetDirectory(id: id, path: path) + } + } + .fileImporter(isPresented: $showLocalImporter, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in + guard case .success(let urls) = result else { return } + localURLs = urls + generateJSON(for: urls) + } + .alert("秒传工具", isPresented: Binding(get: { !localError.isEmpty }, set: { if !$0 { localError = "" } })) { + Button("确定", role: .cancel) { localError = "" } + } message: { + Text(localError) + } + .onAppear { + refreshQueueSnapshot() + if model.isFastTransferRunning || model.hasPendingFastTransferEntries { + mode = .importJSON + importPhase = .ready + } + let defaults = UserDefaults.standard + if defaults.bool(forKey: "guangya.fastTransfer.hasSavedTarget") { + let savedID = defaults.string(forKey: "guangya.fastTransfer.targetID") ?? "" + targetID = savedID.isEmpty ? nil : savedID + targetName = defaults.string(forKey: "guangya.fastTransfer.targetName") ?? "云盘根目录" + } else { + targetID = model.folderPath.last?.id + targetName = model.folderPath.isEmpty ? "云盘根目录" : model.folderPath.map(\.name).joined(separator: " / ") + } + } + // Published emits before its stored value changes. Build from the value + // delivered by the publisher rather than reading the old model array, + // otherwise a freshly parsed JSON can momentarily cache an empty queue. + .onReceive(model.$fastTransferEntries) { entries in + refreshQueueSnapshot(entries: entries, results: model.fastTransferResults) + } + .onReceive(model.$fastTransferResults) { results in + refreshQueueSnapshot(entries: model.fastTransferEntries, results: results) + } + } + + private var importSourceView: some View { + VStack(spacing: 18) { + if importPhase == .parsing { + ProgressView().controlSize(.large) + Text("正在分析 JSON").font(.title3.weight(.semibold)) + Text("正在读取秒传任务和校验信息").font(.callout).foregroundStyle(.secondary) + } else { + Image(systemName: "bolt.badge.clock") + .font(.system(size: 42, weight: .light)).foregroundStyle(.orange) + .frame(width: 88, height: 88) + .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + VStack(spacing: 6) { + Text("导入秒传任务").font(.title2.weight(.bold)) + Text("选择 JSON 来源后会先解析为可检查的任务列表。") + .font(.callout).foregroundStyle(.secondary) + } + HStack(spacing: 14) { + fastTransferSourceButton("粘贴 JSON", icon: "doc.on.clipboard", action: pasteJSONFromClipboard) + fastTransferSourceButton("选择 JSON", icon: "folder.badge.plus", action: chooseJSONFile) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(30) + } + + private func fastTransferSourceButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 12) { + Image(systemName: icon).font(.title2.weight(.semibold)) + Text(title).font(.headline) + } + .frame(width: 190, height: 132) + .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 8)) + .overlay { RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.14), lineWidth: 1) } + } + .buttonStyle(.plain) + .disabled(importPhase == .parsing) + } + + private var importView: some View { + VStack(spacing: 0) { + transferCommandBar + Divider() + taskList + .padding(18) + } + .onChange(of: model.fastTransferProgress.currentName) { _, path in + let pending = queueSnapshot.pending + guard queueSection == .pending, + model.isFastTransferRunning, + let index = pending.firstIndex(where: { $0.entry.path == path }) else { return } + queuePage = index / queuePageSize + } + } + + private var transferCommandBar: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text("秒传任务").font(.headline.weight(.semibold)) + HStack(spacing: 5) { + Image(systemName: "folder") + Text(targetName).lineLimit(1) + } + .font(.caption) + .foregroundStyle(.secondary) + } + Button { showTargetPicker = true } label: { Image(systemName: "folder.badge.gearshape") } + .buttonStyle(.bordered) + .help("选择目标目录") + .disabled(model.isFastTransferRunning) + Divider().frame(height: 28) + Toggle("创建目录", isOn: $createDirectories) + .toggleStyle(.checkbox) + .font(.caption) + .disabled(model.isFastTransferRunning) + Toggle("跳过同名", isOn: $skipExisting) + .toggleStyle(.checkbox) + .font(.caption) + .disabled(model.isFastTransferRunning) + Stepper(value: $model.fastTransferConcurrency, in: 1...20) { + HStack(spacing: 4) { + Image(systemName: "point.3.connected.trianglepath.dotted") + Text("并发 \(model.fastTransferConcurrency)") + .monospacedDigit() + } + .font(.caption) + } + .frame(width: 94) + .disabled(model.isFastTransferRunning) + .help("同时提交的秒传请求数量") + Spacer(minLength: 10) + Button { resetImportSource() } label: { Image(systemName: "arrow.uturn.backward") } + .buttonStyle(.bordered) + .help("重新选择 JSON") + .disabled(model.isFastTransferRunning) + if model.isFastTransferRunning { + Button { + if model.isFastTransferPaused { model.resumeFastTransfer() } + else { model.pauseFastTransfer() } + } label: { + Label(model.isFastTransferPaused ? "继续" : "暂停", systemImage: model.isFastTransferPaused ? "play.fill" : "pause.fill") + } + .buttonStyle(.bordered) + Button(role: .destructive, action: stopImport) { Label("终止", systemImage: "stop.fill") } + .buttonStyle(.bordered) + .help("终止当前任务,保留未执行任务") + } else { + Button { runImport() } label: { + Label("开始秒传 \(pendingEntries.count) 项", systemImage: "bolt.fill") + } + .buttonStyle(.borderedProminent) + .tint(.orange) + .disabled(pendingEntries.isEmpty) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + } + + private var generateView: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("本地文件生成秒传 JSON").font(.headline.weight(.semibold)) + Text(localURLs.isEmpty ? "" : "已选择 \(localURLs.count) 个文件或文件夹") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button { showLocalImporter = true } label: { Label("选择文件或文件夹", systemImage: "folder.badge.plus") } + .buttonStyle(.bordered) + Button { saveGeneratedJSON() } label: { Label("导出 JSON", systemImage: "square.and.arrow.up") } + .buttonStyle(.borderedProminent).disabled(generatedJSON.isEmpty || model.isGeneratingFastTransferJSON) + } + if model.isGeneratingFastTransferJSON || model.fastTransferGenerationProgress.total > 0 { + progressView(model.fastTransferGenerationProgress, title: "GCID 计算进度") + } + if generatedJSON.isEmpty && !model.isGeneratingFastTransferJSON { + ContentUnavailableView("尚未选择本地文件", systemImage: "doc.badge.plus") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + TextEditor(text: .constant(generatedJSON)) + .font(.system(.body, design: .monospaced)) + .scrollContentBackground(.hidden) + .padding(8) + .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 7)) + .overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(0.12), lineWidth: 1) } + .textSelection(.enabled) + } + } + .padding(22) + } + + private var taskList: some View { + let pendingTasks = queueSnapshot.pending + let issueTasks = queueSnapshot.issues + let importedTasks = queueSnapshot.imported + let skippedTasks = queueSnapshot.skipped + let tasks = queueSnapshot.tasks(in: queueSection) + let retryable = retryableResults + let retryableIDs = Set(retryable.map(\.id)) + let pageCount = max(1, Int(ceil(Double(tasks.count) / Double(queuePageSize)))) + let currentPage = min(queuePage, pageCount - 1) + let pageStart = min(currentPage * queuePageSize, tasks.count) + let visibleTasks = Array(tasks.dropFirst(pageStart).prefix(queuePageSize)) + let pageEnd = pageStart + visibleTasks.count + return VStack(alignment: .leading, spacing: 7) { + HStack { + Label(queueSection.title, systemImage: queueSection == .pending ? "list.bullet.rectangle" : (queueSection == .issues ? "exclamationmark.triangle" : (queueSection == .imported ? "checkmark.circle" : "forward.circle"))) + .font(.headline.weight(.semibold)) + Text("\(tasks.count) 项") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if !model.fastTransferEntries.isEmpty { + Button("清空任务") { model.clearFastTransferSession() } + .buttonStyle(.borderless) + .controlSize(.small) + .disabled(model.isFastTransferRunning) + } + } + HStack(spacing: 5) { + queueSectionTab(.pending, count: pendingTasks.count, icon: "list.bullet", tint: .orange) + queueSectionTab(.issues, count: issueTasks.count, icon: "exclamationmark.triangle", tint: .red) + queueSectionTab(.imported, count: importedTasks.count, icon: "checkmark.circle", tint: .green) + queueSectionTab(.skipped, count: skippedTasks.count, icon: "forward.circle", tint: .secondary) + } + .padding(3) + .background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + HStack(spacing: 8) { + Text(tasks.isEmpty ? "暂无任务" : "显示 \(pageStart + 1)-\(pageEnd) / \(tasks.count)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + Spacer() + Picker("每页", selection: $queuePageSize) { + Text("200 条/页").tag(200) + Text("500 条/页").tag(500) + Text("1,000 条/页").tag(1_000) + } + .labelsHidden() + .pickerStyle(.menu) + .controlSize(.small) + .onChange(of: queuePageSize) { _, _ in queuePage = 0 } + Button { queuePage = max(0, currentPage - 1) } label: { Image(systemName: "chevron.left") } + .buttonStyle(.borderless) + .disabled(currentPage == 0) + .help("上一页") + Text("\(currentPage + 1)/\(pageCount)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(minWidth: 38) + Button { queuePage = min(pageCount - 1, currentPage + 1) } label: { Image(systemName: "chevron.right") } + .buttonStyle(.borderless) + .disabled(currentPage >= pageCount - 1) + .help("下一页") + } + if model.isFastTransferRunning || model.fastTransferProgress.total > 0 { + queueProgress + } + if queueSection == .issues && !retryable.isEmpty { + HStack(spacing: 8) { + Label("失败任务可单独重传", systemImage: "exclamationmark.arrow.triangle.2.circlepath") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("重传失败项") { retry(retryable) } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(model.isFastTransferRunning) + } + } + List { + if visibleTasks.isEmpty { + ContentUnavailableView(emptyQueueTitle, systemImage: queueSection == .pending ? "checkmark.circle" : "tray") + } else { + ForEach(visibleTasks, id: \.entry.id) { task in + let entry = task.entry + let isActive = isTaskActive(entry) + HStack(alignment: .top, spacing: 9) { + Group { + if isActive { + if model.isFastTransferPaused { + Image(systemName: "pause.circle.fill").foregroundStyle(.orange) + } else { + ProgressView().controlSize(.small) + } + } else { + Image(systemName: isTaskQueuedForCancellation(entry) ? "xmark.circle" : taskIcon(task.result)) + .foregroundStyle(isTaskQueuedForCancellation(entry) ? .secondary : taskColor(task.result)) + } + } + .frame(width: 18, height: 18) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(entry.name).font(.callout.weight(.medium)).lineLimit(1) + Text(taskStatusTitle(for: entry, result: task.result, isActive: isActive)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(taskColor(task.result, isActive: isActive)) + } + Text(entry.path).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1) + Text(taskMetadata(entry)) + .font(.caption2.monospaced()).foregroundStyle(.tertiary).lineLimit(1) + if let result = task.result { Text(result.message).font(.caption2).foregroundStyle(taskColor(result)).lineLimit(1) } + } + Spacer(minLength: 4) + if let result = task.result, + result.state == .failed, + retryableIDs.contains(result.id) { + Button { retry([result]) } label: { Image(systemName: "arrow.clockwise") } + .buttonStyle(.borderless) + .help("重传此任务") + .disabled(model.isFastTransferRunning) + } + if isActive { + Button(role: .destructive, action: stopImport) { Image(systemName: "stop.fill") } + .buttonStyle(.borderless) + .help("终止当前传输,保留未执行任务") + } else if task.result == nil { + Button { cancelTask(entry) } label: { Image(systemName: "xmark") } + .buttonStyle(.borderless) + .help("取消任务") + .disabled(isTaskQueuedForCancellation(entry)) + } + } + .contextMenu { + Button { copyTaskPath(entry.path) } label: { Label("复制路径", systemImage: "doc.on.doc") } + if task.result == nil { + Button(role: .destructive) { cancelTask(entry) } label: { Label("取消任务", systemImage: "xmark.circle") } + .disabled(isTaskQueuedForCancellation(entry)) + } + } + } + } + } + .listStyle(.plain) + .frame(minHeight: 145, maxHeight: .infinity) + } + } + + private func queueSectionTab(_ section: FastTransferQueueSection, count: Int, icon: String, tint: Color) -> some View { + let isSelected = queueSection == section + return Button { + queueSection = section + queuePage = 0 + } label: { + HStack(spacing: 5) { + Image(systemName: icon) + Text(section.title) + Text("\(count)").monospacedDigit() + } + .font(.caption.weight(isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? tint : .secondary) + .padding(.horizontal, 10) + .frame(height: 28) + .frame(maxWidth: .infinity) + .background(isSelected ? tint.opacity(0.14) : .clear, in: RoundedRectangle(cornerRadius: 5, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel("\(section.title),\(count) 项") + } + + private var emptyQueueTitle: String { + switch queueSection { + case .pending: return "主队列已处理完毕" + case .issues: return "没有出错任务" + case .imported: return "还没有成功的任务" + case .skipped: return "没有跳过的任务" + } + } + + private var pendingEntries: [FastTransferEntry] { + queueSnapshot.pending.map(\.entry) + } + + private var latestResultsByEntryID: [UUID: FastTransferResult] { + queueSnapshot.latestResults + } + + private func taskIcon(_ result: FastTransferResult?) -> String { + guard let result else { return "clock" } + return resultIcon(result.state) + } + + private func taskColor(_ result: FastTransferResult?) -> Color { + guard let result else { return .orange } + return resultColor(result.state) + } + + private func taskColor(_ result: FastTransferResult?, isActive: Bool) -> Color { + isActive ? (model.isFastTransferPaused ? .orange : .accentColor) : taskColor(result) + } + + private func isTaskActive(_ entry: FastTransferEntry) -> Bool { + model.isFastTransferRunning && model.fastTransferProgress.activeEntryIDs.contains(entry.id) + } + + private func taskStatusTitle(for entry: FastTransferEntry, result: FastTransferResult?, isActive: Bool) -> String { + if isActive { return model.isFastTransferPaused ? "已暂停" : "处理中" } + if result == nil, isTaskQueuedForCancellation(entry) { return "待取消" } + guard let result else { return "待处理" } + return resultTitle(result.state) + } + + private func isTaskQueuedForCancellation(_ entry: FastTransferEntry) -> Bool { + model.cancelledFastTransferEntryIDs.contains(entry.id) + } + + private var importedTaskCount: Int { + model.fastTransferProgress.imported + } + + private var skippedTaskCount: Int { + model.fastTransferProgress.skipped + } + + private var failedTaskCount: Int { + model.fastTransferProgress.failed + } + + private var cancelledTaskCount: Int { + model.fastTransferProgress.cancelled + } + + private var queueProgress: some View { + let progress = model.fastTransferProgress + let activeEntries = model.fastTransferActiveEntries + return VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 8) { + Label(model.isFastTransferPaused ? "任务已暂停" : "任务进度", systemImage: model.isFastTransferPaused ? "pause.circle" : "chart.bar.xaxis") + .font(.caption.weight(.semibold)) + Spacer() + Text("\(progress.processed)/\(progress.total)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + TimelineView(.periodic(from: .now, by: 1)) { context in + if model.isFastTransferPaused { + Text("已暂停") + } else if let remaining = model.fastTransferEstimatedRemaining(at: context.date) { + Text("预计剩余 \(fastTransferDurationText(remaining))") + } else if model.isFastTransferRunning, progress.processed == 0 { + Text("正在估算") + } + } + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + ProgressView(value: progress.fraction) + .tint(.orange) + HStack(spacing: 12) { + Label("成功 \(importedTaskCount)", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + Label("已跳过 \(skippedTaskCount)", systemImage: "forward.fill") + .foregroundStyle(.secondary) + if failedTaskCount > 0 { + Label("失败 \(failedTaskCount)", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + } + if cancelledTaskCount > 0 { + Label("已终止 \(cancelledTaskCount)", systemImage: "stop.circle.fill") + .foregroundStyle(.secondary) + } + } + .font(.caption2) + if !activeEntries.isEmpty { + Divider() + HStack(spacing: 6) { + Text(model.isFastTransferPaused ? "已暂停" : "正在上传") + .font(.caption2.weight(.semibold)) + Text("\(activeEntries.count)/\(model.fastTransferConcurrency)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + ForEach(activeEntries) { entry in + HStack(spacing: 7) { + if model.isFastTransferPaused { + Image(systemName: "pause.circle.fill").foregroundStyle(.orange) + } else { + ProgressView().controlSize(.small) + } + Text(entry.name).lineLimit(1) + Spacer(minLength: 4) + Text(byteCount(entry.size)).foregroundStyle(.tertiary) + } + .font(.caption2) + } + } else if !progress.currentName.isEmpty { + Text(progress.currentName) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(10) + .background(Color.orange.opacity(0.07), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .accessibilityElement(children: .combine) + .accessibilityLabel("秒传任务进度,已处理 \(progress.processed) 项,共 \(progress.total) 项") + } + + private func fastTransferDurationText(_ interval: TimeInterval) -> String { + let total = max(0, Int(interval.rounded(.up))) + if total >= 3_600 { return "\(total / 3_600)小时\((total % 3_600) / 60)分" } + if total >= 60 { return "\(total / 60)分\(total % 60)秒" } + return "\(total)秒" + } + + private var retryableResults: [FastTransferResult] { + queueSnapshot.issues.compactMap(\.result).filter { $0.state == .failed && !queueSnapshot.retriedResultIDs.contains($0.id) } + } + + private func resultTitle(_ state: FastTransferResult.State) -> String { + switch state { + case .imported: return "已完成" + case .skipped: return "已跳过" + case .failed: return "失败" + case .cancelled: return "已终止" + } + } + + private func byteCount(_ value: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: value) + } + + private func taskMetadata(_ entry: FastTransferEntry) -> String { + let hashValue = entry.etag ?? entry.gcid ?? "-" + return "\(byteCount(entry.size)) · \(entry.hashKind) \(hashValue)" + } + + private func progressView(_ progress: FastTransferProgress, title: String) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack { + Text(title).font(.caption.weight(.semibold)) + Spacer() + Text("\(progress.processed)/\(progress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + ProgressView(value: progress.fraction) + if !progress.currentName.isEmpty { Text(progress.currentName).font(.caption2).foregroundStyle(.secondary).lineLimit(1) } + } + .padding(10) + .background(Color.orange.opacity(0.07), in: RoundedRectangle(cornerRadius: 7)) + } + + private func validateJSON() { + analyzeJSON(jsonText) + } + + private func analyzeJSON(_ text: String) { + let source = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !source.isEmpty else { + localError = "JSON 内容为空" + return + } + jsonText = text + importPhase = .parsing + Task { + await Task.yield() + do { + try await Task.sleep(for: .milliseconds(120)) + let entries = try model.parseFastTransferJSON(text) + model.replaceFastTransferEntries(entries) + // State publishers are delivered on a later render pass. Refresh + // synchronously so a freshly parsed JSON never opens an empty + // task panel while SwiftUI waits for that notification. + refreshQueueSnapshot() + queueSection = .pending + queuePage = 0 + importPhase = .ready + } catch { + model.clearFastTransferSession() + importPhase = .chooseSource + localError = error.localizedDescription + } + } + } + + private func pasteJSONFromClipboard() { + guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { + localError = "剪贴板中没有可用的 JSON 文本" + return + } + analyzeJSON(text) + } + + private func removeTask(_ entry: FastTransferEntry) { + model.fastTransferEntries.removeAll { $0.id == entry.id } + } + + private func cancelTask(_ entry: FastTransferEntry) { + if model.isFastTransferRunning { + model.cancelFastTransferEntry(entry.id) + } else { + removeTask(entry) + } + } + + private func saveTargetDirectory(id: String?, path: String) { + targetID = id + targetName = path + let defaults = UserDefaults.standard + defaults.set(true, forKey: "guangya.fastTransfer.hasSavedTarget") + defaults.set(id ?? "", forKey: "guangya.fastTransfer.targetID") + defaults.set(path, forKey: "guangya.fastTransfer.targetName") + } + + private func copyTaskPath(_ path: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(path, forType: .string) + } + + private func chooseJSONFile() { + let panel = NSOpenPanel() + panel.title = "选择秒传 JSON" + panel.prompt = "打开" + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + guard panel.runModal() == .OK, let url = panel.url else { return } + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + do { + analyzeJSON(try String(contentsOf: url, encoding: .utf8)) + } catch { + localError = error.localizedDescription + } + } + + private func resetImportSource() { + guard !model.isFastTransferRunning else { return } + jsonText = "" + model.clearFastTransferSession() + importPhase = .chooseSource + } + + private func refreshQueueSnapshot(entries: [FastTransferEntry]? = nil, results: [FastTransferResult]? = nil) { + queueSnapshot = FastTransferQueueSnapshot(entries: entries ?? model.fastTransferEntries, results: results ?? model.fastTransferResults) + let count = queueSnapshot.tasks(in: queueSection).count + let maxPage = max(0, Int(ceil(Double(count) / Double(queuePageSize))) - 1) + if queuePage > maxPage { queuePage = maxPage } + } + + private func runImport() { + guard !pendingEntries.isEmpty else { return } + startImport(pendingEntries) + } + + private func retry(_ results: [FastTransferResult]) { + guard !results.isEmpty else { return } + let retryingResultIDs = Dictionary(uniqueKeysWithValues: results.map { ($0.entry.id, $0.id) }) + startImport(results.map(\.entry), retryingResultIDs: retryingResultIDs) + } + + private func startImport(_ entries: [FastTransferEntry], retryingResultIDs: [UUID: UUID] = [:]) { + importTask = Task { + _ = await model.importFastTransferEntries( + entries, + parentID: targetID, + createDirectories: createDirectories, + skipExisting: skipExisting, + retryingResultIDs: retryingResultIDs + ) + importTask = nil + } + } + + private func stopImport() { + model.cancelFastTransfer() + importTask?.cancel() + } + + private func generateJSON(for urls: [URL]) { + Task { + let accessed = urls.map { ($0, $0.startAccessingSecurityScopedResource()) } + defer { accessed.forEach { if $0.1 { $0.0.stopAccessingSecurityScopedResource() } } } + do { + generatedEntries = try await model.generateFastTransferEntries(from: urls) + generatedJSON = try model.fastTransferJSON(for: generatedEntries) + if model.fastTransferGenerationProgress.failed > 0 { + localError = "已生成 \(generatedEntries.count) 项,\(model.fastTransferGenerationProgress.failed) 项无法读取。" + } + } catch { + localError = error.localizedDescription + } + } + } + + private func saveGeneratedJSON() { + let panel = NSSavePanel() + panel.nameFieldStringValue = "光鸭秒传.json" + panel.allowedContentTypes = [.json] + guard panel.runModal() == .OK, let url = panel.url else { return } + do { try generatedJSON.write(to: url, atomically: true, encoding: .utf8) } + catch { localError = error.localizedDescription } + } + + private func resultIcon(_ state: FastTransferResult.State) -> String { + switch state { + case .imported: return "checkmark.circle.fill" + case .skipped: return "forward.circle.fill" + case .failed: return "xmark.circle.fill" + case .cancelled: return "stop.circle.fill" + } + } + + private func resultColor(_ state: FastTransferResult.State) -> Color { + switch state { + case .imported: return .green + case .skipped: return .orange + case .failed: return .red + case .cancelled: return .secondary + } + } +} + private struct UnifiedScanToolPage: View { @ObservedObject var model: AppModel @State private var selectedRootID: String? @State private var selectedRootName = "整个云盘" @State private var showFolderPicker = false + @State private var duplicateMinimumSizeMB = 0 var body: some View { VStack(spacing: 16) { ToolPageCard(icon: "magnifyingglass.circle.fill", tint: .orange, title: "统一扫描", detail: "选择扫描范围后,可分别扫描空文件夹、重复文件及相似文件夹;扫描不会修改云端文件。") { HStack { VStack(alignment: .leading, spacing: 2) { Text("扫描范围").font(.caption).foregroundStyle(.secondary); Text(selectedRootName).font(.callout.weight(.semibold)).lineLimit(1) }; Spacer(); Button("浏览云盘…") { showFolderPicker = true }.buttonStyle(.bordered); Button("全盘扫描") { selectedRootID = nil; selectedRootName = "整个云盘" }.buttonStyle(.borderedProminent).tint(.orange) } HStack(spacing: 10) { scanButton(.emptyFolders, "扫描空文件夹", "folder.badge.minus"); scanButton(.duplicates, "扫描重复文件", "square.on.square"); scanButton(.similarFolders, "扫描相似文件夹", "rectangle.3.group") } + HStack(spacing: 10) { + Label("重复文件大小筛选", systemImage: "line.3.horizontal.decrease.circle") + .font(.caption.weight(.semibold)).foregroundStyle(.secondary) + Stepper(value: $duplicateMinimumSizeMB, in: 0...50_000, step: 10) { + Text(duplicateMinimumSizeMB == 0 ? "不跳过小文件" : "跳过小于 \(duplicateMinimumSizeMB) MB 的文件") + .font(.caption).foregroundStyle(.secondary) + } + .disabled(model.isScanning) + Spacer() + } } if model.activeScanRequest != nil || model.scanResult != nil { ScanToolPage(model: model, kind: model.activeScanRequest?.kind ?? model.scanResult!.request.kind) @@ -1268,7 +2345,10 @@ private struct UnifiedScanToolPage: View { } private func scanActionButton(_ kind: ScanKind, _ title: String, _ icon: String, showsProgress: Bool) -> some View { - Button { model.startScan(ScanRequest(kind: kind, scope: selectedRootID == nil ? .wholeDrive : .recursiveCurrentFolder, rootID: selectedRootID, rootName: selectedRootName, excludeFilesSmallerThan: nil)) } label: { + let minimumSize = kind == .duplicates && duplicateMinimumSizeMB > 0 + ? Int64(duplicateMinimumSizeMB) * 1_024 * 1_024 + : nil + return Button { model.startScan(ScanRequest(kind: kind, scope: selectedRootID == nil ? .wholeDrive : .recursiveCurrentFolder, rootID: selectedRootID, rootName: selectedRootName, excludeFilesSmallerThan: minimumSize)) } label: { HStack(spacing: 6) { if showsProgress { ProgressView().controlSize(.small) } Label(title, systemImage: icon) @@ -1286,16 +2366,59 @@ private struct UnifiedScanToolPage: View { } } +private enum ScanFolderPickerSort: String, CaseIterable, Identifiable { + case nameAscending + case nameDescending + case lengthAscending + case lengthDescending + + var id: String { rawValue } + + var title: String { + switch self { + case .nameAscending: return "名称 A-Z" + case .nameDescending: return "名称 Z-A" + case .lengthAscending: return "名称长度:短到长" + case .lengthDescending: return "名称长度:长到短" + } + } +} + struct ScanFolderPickerSheet: View { @ObservedObject var model: AppModel var title = "选择扫描文件夹" var selectionLabel = "选择此文件夹" + var allowsFilteringAndSorting = false let onSelect: (String?, String) -> Void @Environment(\.dismiss) private var dismiss @State private var path: [FolderPath] = [] + @State private var folderSearchText = "" + @State private var folderSort: ScanFolderPickerSort = .nameAscending private var currentID: String? { path.last?.id } private var currentName: String { path.isEmpty ? "整个云盘" : path.map(\.name).joined(separator: " / ") } + private var visibleFolders: [CloudFile] { + let query = folderSearchText.trimmingCharacters(in: .whitespacesAndNewlines) + let filtered = query.isEmpty + ? model.scanPickerFolders + : model.scanPickerFolders.filter { $0.name.localizedCaseInsensitiveContains(query) } + return filtered.sorted { lhs, rhs in + switch folderSort { + case .nameAscending: + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + case .nameDescending: + return lhs.name.localizedStandardCompare(rhs.name) == .orderedDescending + case .lengthAscending: + return lhs.name.count == rhs.name.count + ? lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + : lhs.name.count < rhs.name.count + case .lengthDescending: + return lhs.name.count == rhs.name.count + ? lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + : lhs.name.count > rhs.name.count + } + } + } var body: some View { ZStack { @@ -1319,14 +2442,34 @@ struct ScanFolderPickerSheet: View { folderBreadcrumb + if allowsFilteringAndSorting { + HStack(spacing: 10) { + Label("当前文件夹", systemImage: "folder") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + TextField("筛选目录", text: $folderSearchText) + .textFieldStyle(.roundedBorder) + Menu { + Picker("排序", selection: $folderSort) { + ForEach(ScanFolderPickerSort.allCases) { option in + Text(option.title).tag(option) + } + } + } label: { + Label("排序", systemImage: "arrow.up.arrow.down") + } + .menuStyle(.borderlessButton) + } + } + Group { if model.isLoadingScanPicker { VStack(spacing: 12) { ProgressView(); Text("正在读取文件夹…").foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if model.scanPickerFolders.isEmpty { + } else if visibleFolders.isEmpty { ContentUnavailableView("没有子文件夹", systemImage: "folder", description: Text("可以选择当前文件夹作为扫描范围。")) } else { - List(model.scanPickerFolders) { folder in + List(visibleFolders) { folder in Button { open(folder) } label: { HStack(spacing: 12) { Image(systemName: "folder.fill") @@ -1393,18 +2536,21 @@ struct ScanFolderPickerSheet: View { private func open(_ folder: CloudFile) { guard !model.isLoadingScanPicker else { return } path.append(FolderPath(id: folder.id, name: folder.name)) + folderSearchText = "" Task { await model.loadScanPickerFolders(parentID: folder.id) } } private func navigateToRoot() { guard !model.isLoadingScanPicker else { return } path = [] + folderSearchText = "" Task { await model.loadScanPickerFolders() } } private func navigate(to index: Int) { guard !model.isLoadingScanPicker, path.indices.contains(index) else { return } path.removeSubrange((index + 1).. = [] + @State private var automaticallySelectedSimilarIDs: Set = [] @State private var showDeletionConfirmation = false @State private var detailFolder: CloudFile? + @State private var duplicateQuickSelectMinimumMB = 100 private var resultItems: [ScanItem] { if model.activeScanRequest?.kind == kind, model.isScanning { return model.liveScanItems } @@ -1446,28 +2594,42 @@ private struct ScanToolPage: View { if resultItems.isEmpty { EmptyState(icon: kind.icon, title: model.isScanning ? "正在扫描\(kind.title)" : "没有发现\(kind.title)", subtitle: model.isScanning ? "符合当前扫描类型的项目会显示在这里。" : "本次扫描没有发现符合条件的项目。") } else { - if kind == .emptyFolders { Toggle("全选空文件夹", isOn: Binding(get: { selectedIDs.count == resultItems.count }, set: { selectedIDs = $0 ? Set(resultItems.map(\.id)) : [] })) } + if kind == .emptyFolders { + HStack { + Toggle("全选空文件夹", isOn: Binding(get: { emptyFolderIDs.isSubset(of: selectedIDs) && !emptyFolderIDs.isEmpty }, set: { selectedIDs = $0 ? selectedIDs.union(emptyFolderIDs) : selectedIDs.subtracting(emptyFolderIDs) })) + Spacer() + Text("已选 \(selectedIDs.intersection(emptyFolderIDs).count) 个").font(.caption.weight(.semibold)).foregroundStyle(.orange) + emptyQuickSelectionMenu + } + } else if kind == .duplicates { HStack { Text("勾选项将被删除;每组始终至少保留一个文件。").font(.caption).foregroundStyle(.secondary) Spacer() - Text("已选 \(selectedIDs.count) 项 · \(selectedDuplicateSizeText)").font(.caption.weight(.semibold)).monospacedDigit().foregroundStyle(.blue) + Text("已选 \(selectedFileIDsForDeletion.count) 项 · \(selectedDuplicateSizeText)").font(.caption.weight(.semibold)).monospacedDigit().foregroundStyle(.blue) duplicateQuickSelectionMenu } - } else { Text("每组默认保留第一项;勾选其余项后才可删除。请先逐项复核。").font(.caption).foregroundStyle(.secondary) } + } else { + HStack { + Text("每组已默认勾选除第一项外的文件夹;请根据完整路径复核保留项。").font(.caption).foregroundStyle(.secondary) + Spacer() + Text("已选 \(selectedFileIDsForDeletion.count) 个").font(.caption.weight(.semibold)).foregroundStyle(.green) + similarQuickSelectionMenu + } + } ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(resultItems) { item in scanResultRow(item) } }.padding(.vertical, 2) } .frame(maxHeight: .infinity) Button(role: .destructive) { showDeletionConfirmation = true } label: { HStack(spacing: 7) { if model.isDeletingScanResults { ProgressView().controlSize(.small) } - Label(model.isDeletingScanResults ? deletionButtonTitle : (kind == .emptyFolders ? "删除已选 \(selectedIDs.count) 个空文件夹" : "删除已选 \(selectedIDs.count) 项"), systemImage: "trash") + Label(model.isDeletingScanResults ? deletionButtonTitle : (kind == .emptyFolders ? "删除已选 \(selectedFileIDsForDeletion.count) 个空文件夹" : "删除已选 \(selectedFileIDsForDeletion.count) 项"), systemImage: "trash") } - }.buttonStyle(.borderedProminent).disabled(selectedIDs.isEmpty || model.isScanning || model.isDeletingScanResults) + }.buttonStyle(.borderedProminent).disabled(selectedFileIDsForDeletion.isEmpty || model.isScanning || model.isDeletingScanResults) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .confirmationDialog(kind == .emptyFolders ? "确认删除已选空文件夹?" : "确认删除已选项目?", isPresented: $showDeletionConfirmation) { - Button(kind == .emptyFolders ? "删除 \(selectedIDs.count) 个文件夹" : "删除 \(selectedIDs.count) 项", role: .destructive) { + Button(kind == .emptyFolders ? "删除 \(selectedFileIDsForDeletion.count) 个文件夹" : "删除 \(selectedFileIDsForDeletion.count) 项", role: .destructive) { let ids = selectedFileIDsForDeletion Task { if kind == .emptyFolders { await model.deleteScannedEmptyFolders(ids) } @@ -1477,6 +2639,14 @@ private struct ScanToolPage: View { } } message: { Text(kind == .emptyFolders ? "执行前会逐个重新验证为空,发生变化的目录会被跳过。" : "此操作不可撤销。请确认每个分组至少保留一项。") } .sheet(item: $detailFolder) { FileDetailsSheet(model: model, file: $0) } + .onAppear { autoSelectSimilarFolders() } + .onChange(of: scanItemSignature) { _, _ in autoSelectSimilarFolders() } + .onChange(of: model.scanResult?.id) { _, resultID in + if resultID == nil && model.isScanning { + selectedIDs.removeAll() + automaticallySelectedSimilarIDs.removeAll() + } + } } private var resultSummary: String { @@ -1507,6 +2677,24 @@ private struct ScanToolPage: View { "正在删除 \(model.scanDeletionProgress.completed)/\(model.scanDeletionProgress.total)" } + private var emptyFolderIDs: Set { + Set(resultItems.compactMap { item in + guard case .emptyFolder(let folder) = item else { return nil } + return folder.id + }) + } + + private var scanItemSignature: String { + resultItems.map { item in + switch item { + case .emptyFolder(let folder): return folder.id + case .duplicate(let group): return group.files.map(\.id).joined(separator: ",") + case .similar(let group): return group.folders.map(\.id).joined(separator: ",") + } + } + .joined(separator: "|") + } + private var scanTint: Color { switch kind { case .emptyFolders: return .orange @@ -1523,6 +2711,9 @@ private struct ScanToolPage: View { Button("保留最大文件,选择其余项") { selectDuplicateFilesKeeping { $0.max { ($0.size ?? 0) < ($1.size ?? 0) } } } Button("保留最新文件,选择其余项") { selectDuplicateFilesKeeping { $0.max { $0.modifiedAt < $1.modifiedAt } } } Divider() + Stepper("阈值:\(duplicateQuickSelectMinimumMB) MB", value: $duplicateQuickSelectMinimumMB, in: 1...50_000, step: 10) + Button("选择大于 \(duplicateQuickSelectMinimumMB) MB 的文件") { selectDuplicateFilesLargerThan(Int64(duplicateQuickSelectMinimumMB) * 1_024 * 1_024) } + Divider() Button("清除快速选择") { clearDuplicateSelection() } } label: { Label("快速选择", systemImage: "wand.and.stars") @@ -1531,6 +2722,31 @@ private struct ScanToolPage: View { .fixedSize() } + private var emptyQuickSelectionMenu: some View { + Menu { + Button("选择全部空文件夹") { selectedIDs.formUnion(emptyFolderIDs) } + Button("清除空文件夹选择") { selectedIDs.subtract(emptyFolderIDs) } + } label: { + Label("快速选择", systemImage: "wand.and.stars") + } + .menuStyle(.borderlessButton) + .fixedSize() + } + + private var similarQuickSelectionMenu: some View { + Menu { + Button("保留路径最短,选择其余项") { selectSimilarFoldersKeeping { shortestPathFile(in: $0) } } + Button("保留路径最长,选择其余项") { selectSimilarFoldersKeeping { longestPathFile(in: $0) } } + Divider() + Button("恢复默认选择") { autoSelectSimilarFolders(force: true) } + Button("清除相似文件夹选择") { clearSimilarSelection() } + } label: { + Label("快速选择", systemImage: "wand.and.stars") + } + .menuStyle(.borderlessButton) + .fixedSize() + } + private func selectDuplicateFilesKeeping(_ choose: ([CloudFile]) -> CloudFile?) { for item in resultItems { guard case .duplicate(let group) = item, let kept = choose(group.files) else { continue } @@ -1548,6 +2764,55 @@ private struct ScanToolPage: View { selectedIDs.subtract(ids) } + private func selectDuplicateFilesLargerThan(_ minimumSize: Int64) { + for item in resultItems { + guard case .duplicate(let group) = item else { continue } + let groupIDs = Set(group.files.map(\.id)) + var targets = group.files.filter { ($0.size ?? 0) > minimumSize } + // A duplicate group must retain one resource even when every file + // is above the configured threshold. + if targets.count == group.files.count, + let keep = group.files.min(by: { ($0.size ?? 0) < ($1.size ?? 0) }) { + targets.removeAll { $0.id == keep.id } + } + selectedIDs.subtract(groupIDs) + selectedIDs.formUnion(targets.map(\.id)) + } + } + + private func selectSimilarFoldersKeeping(_ choose: ([CloudFile]) -> CloudFile?) { + for item in resultItems { + guard case .similar(let group) = item, let kept = choose(group.folders) else { continue } + let groupIDs = Set(group.folders.map(\.id)) + selectedIDs.subtract(groupIDs) + selectedIDs.formUnion(groupIDs.subtracting([kept.id])) + automaticallySelectedSimilarIDs.formUnion(groupIDs) + } + } + + private func clearSimilarSelection() { + let ids = Set(resultItems.flatMap { item -> [String] in + guard case .similar(let group) = item else { return [] } + return group.folders.map(\.id) + }) + selectedIDs.subtract(ids) + } + + private func autoSelectSimilarFolders(force: Bool = false) { + guard kind == .similarFolders else { return } + if force { + clearSimilarSelection() + automaticallySelectedSimilarIDs.removeAll() + } + for item in resultItems { + guard case .similar(let group) = item else { continue } + for folder in group.folders.dropFirst() where !automaticallySelectedSimilarIDs.contains(folder.id) { + selectedIDs.insert(folder.id) + automaticallySelectedSimilarIDs.insert(folder.id) + } + } + } + private func shortestPathFile(in files: [CloudFile]) -> CloudFile? { files.min { if $0.pathDepth != $1.pathDepth { return $0.pathDepth < $1.pathDepth } @@ -1575,6 +2840,17 @@ private struct ScanToolPage: View { ) } + private func similarSelectionBinding(for folder: CloudFile, in group: SimilarFolderGroup) -> Binding { + Binding( + get: { selectedIDs.contains(folder.id) }, + set: { shouldDelete in + if !shouldDelete { selectedIDs.remove(folder.id); return } + let selectedInGroup = group.folders.filter { selectedIDs.contains($0.id) }.count + if selectedInGroup < group.folders.count - 1 { selectedIDs.insert(folder.id) } + } + ) + } + private var selectedFileIDsForDeletion: Set { Set(resultItems.flatMap { item -> [String] in switch item { @@ -1588,7 +2864,20 @@ private struct ScanToolPage: View { @ViewBuilder private func scanResultRow(_ item: ScanItem) -> some View { switch item { case .emptyFolder(let folder): - Toggle(isOn: Binding(get: { selectedIDs.contains(item.id) }, set: { if $0 { selectedIDs.insert(item.id) } else { selectedIDs.remove(item.id) } })) { HStack { Image(systemName: "folder").foregroundStyle(.orange); Text(folder.name); Spacer(); Text("空文件夹").font(.caption).foregroundStyle(.secondary); Text(folder.id).font(.caption.monospaced()).foregroundStyle(.secondary) } } + Toggle(isOn: Binding(get: { selectedIDs.contains(folder.id) }, set: { if $0 { selectedIDs.insert(folder.id) } else { selectedIDs.remove(folder.id) } })) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "folder").foregroundStyle(.orange).frame(width: 22) + VStack(alignment: .leading, spacing: 3) { + Text(folder.name).font(.callout.weight(.medium)).lineLimit(1) + Text(folder.cloudPath).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled) + } + Spacer() + VStack(alignment: .trailing, spacing: 3) { + Text("空文件夹").font(.caption.weight(.semibold)).foregroundStyle(.orange) + Text(folder.id).font(.caption2.monospaced()).foregroundStyle(.tertiary) + } + } + } .padding(10).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) .contextMenu { Button { detailFolder = folder } label: { Label("查看详情", systemImage: "info.circle") } @@ -1618,11 +2907,26 @@ private struct ScanToolPage: View { }.padding(12).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) case .similar(let group): VStack(alignment: .leading, spacing: 8) { - Text("相似名称组 · \(group.folders.count) 个文件夹(默认保留第一项)").font(.caption.weight(.bold)).foregroundStyle(.green) - ForEach(Array(group.folders.enumerated()), id: \.element.id) { index, folder in - Toggle(isOn: Binding(get: { selectedIDs.contains(folder.id) }, set: { if $0 { selectedIDs.insert(folder.id) } else { selectedIDs.remove(folder.id) } })) { - HStack { Image(systemName: "folder.fill").foregroundStyle(.orange); Text(folder.name); Spacer(); Text(index == 0 ? "保留" : folder.id).font(.caption.monospaced()).foregroundStyle(index == 0 ? .green : .secondary) } - }.disabled(index == 0) + HStack { + Text("相似名称组 · \(group.folders.count) 个文件夹").font(.caption.weight(.bold)).foregroundStyle(.green) + Spacer() + Text("自动保留第一项").font(.caption2).foregroundStyle(.secondary) + } + ForEach(group.folders) { folder in + let selected = selectedIDs.contains(folder.id) + Toggle(isOn: similarSelectionBinding(for: folder, in: group)) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "folder.fill").foregroundStyle(.orange).frame(width: 22) + VStack(alignment: .leading, spacing: 3) { + Text(folder.name).font(.callout.weight(.medium)).lineLimit(1) + Text(folder.cloudPath).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2).textSelection(.enabled) + } + Spacer() + Text(selected ? "将删除" : "保留") + .font(.caption.weight(.semibold)) + .foregroundStyle(selected ? .red : .green) + } + } } }.padding(12).background(Color.black.opacity(0.035), in: RoundedRectangle(cornerRadius: 12)) } @@ -1680,7 +2984,7 @@ private struct SimilarFoldersToolPage: View { } } -private struct TMDBToolPage: View { +struct TMDBToolPage: View { @ObservedObject var model: AppModel @State private var workflow = TMDBWorkflowConfig.load() @State private var sourceID: String? @@ -1692,7 +2996,7 @@ private struct TMDBToolPage: View { var body: some View { VStack(spacing: 16) { ToolPageCard(icon: "film.stack", tint: .purple, title: "TMDB 刮削任务", detail: "按“选择范围 → 建立任务 → 预览匹配 → 确认执行”运行;预览阶段不会修改云盘。") { - HStack { Image(systemName: model.tmdbAPIKey.isEmpty ? "xmark.circle.fill" : "checkmark.circle.fill").foregroundStyle(model.tmdbAPIKey.isEmpty ? .red : .green); Text(model.tmdbAPIKey.isEmpty ? "请先在工作区设置配置 TMDB API Key" : "TMDB API Key 已配置") } + HStack { Image(systemName: model.tmdbAPIKey.isEmpty ? "xmark.circle.fill" : "checkmark.circle.fill").foregroundStyle(model.tmdbAPIKey.isEmpty ? .red : .green); Text(model.tmdbAPIKey.isEmpty ? "请先在影视模式侧栏配置 TMDB API Key" : "TMDB API Key 已配置") } Picker("媒体类型", selection: $workflow.mediaKind) { ForEach(TMDBMediaKind.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented) Picker("执行方式", selection: $workflow.operation) { ForEach(TMDBOperation.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented) scopePicker(title: "源目录", name: $sourceName, id: $sourceID) diff --git a/guangya_mac/DualPanel.swift b/guangya_mac/DualPanel.swift new file mode 100644 index 0000000..b8a1a71 --- /dev/null +++ b/guangya_mac/DualPanel.swift @@ -0,0 +1,519 @@ +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Dual Panel View + +struct DualPanelView: View { + @ObservedObject var model: AppModel + @StateObject private var dualState: DualPanelState + + init(model: AppModel) { + self.model = model + _dualState = StateObject(wrappedValue: DualPanelState(api: model.api)) + } + + var body: some View { + HSplitView { + PanelFilesBrowser(model: model, panel: dualState.leftPanel, counterpart: dualState.rightPanel, label: "左") + .frame(minWidth: 340) + + PanelFilesBrowser(model: model, panel: dualState.rightPanel, counterpart: dualState.leftPanel, label: "右") + .frame(minWidth: 340) + } + .task { + await dualState.activate() + // Sync left panel with current model state + if dualState.leftPanel.files.isEmpty { + await dualState.leftPanel.loadFiles(folderID: model.folderPath.last?.id) + dualState.leftPanel.folderPath = model.folderPath + } + } + } +} + +// MARK: - Dual Panel State + +@MainActor +final class DualPanelState: ObservableObject { + let leftPanel: PanelState + let rightPanel: PanelState + let api: GuangyaAPI + + init(api: GuangyaAPI) { + self.api = api + self.leftPanel = PanelState(api: api, id: 0) + self.rightPanel = PanelState(api: api, id: 1) + } + + func activate() async { + // Load initial data if not already loaded + if leftPanel.files.isEmpty { + await leftPanel.loadFiles() + } + if rightPanel.files.isEmpty { + await rightPanel.loadFiles() + } + } +} + +// MARK: - Panel State (independent navigation for each panel) + +@MainActor +final class PanelState: ObservableObject { + let api: GuangyaAPI + let id: Int // 0 = left, 1 = right + + @Published var files: [CloudFile] = [] + @Published var folderPath: [FolderPath] = [] + @Published var isLoadingFiles = false + @Published var isMovingFiles = false + @Published var selectedFileIDs: Set = [] + @Published var section: WorkspaceSection = .files + + private var currentPage: Int = 0 + private let pageSize: Int = 100 + + var currentFolderID: String? { + if let last = folderPath.last { return last.id } + return nil + } + + init(api: GuangyaAPI, id: Int) { + self.api = api + self.id = id + } + + func loadFiles(folderID: String? = nil) async { + isLoadingFiles = true + defer { isLoadingFiles = false } + + do { + let response = try await api.fsFiles(parentID: folderID, page: currentPage, pageSize: pageSize) + self.files = extractCloudFiles(from: response) + } catch { + self.files = [] + print("⚠️ [Panel \(id)] loadFiles failed: \(error)") + } + } + + func navigateToRoot() async { + folderPath = [] + await loadFiles(folderID: nil) + } + + func navigateToFolder(at index: Int) async { + guard index < folderPath.count else { return } + let folderID = folderPath[index].id + let newPath = Array(folderPath.prefix(index + 1)) + folderPath = newPath + await loadFiles(folderID: folderID) + } + + func openFolder(_ folder: CloudFile) async { + folderPath.append(FolderPath(id: folder.id, name: folder.name)) + await loadFiles(folderID: folder.id) + } +} + +// MARK: - Panel Browser View (one side of the dual panel) + +struct PanelFilesBrowser: View { + @ObservedObject var model: AppModel + @ObservedObject var panel: PanelState + @ObservedObject var counterpart: PanelState + let label: String + + @State private var showFileDetail: CloudFile? + @State private var isDropTargeted = false + + var body: some View { + VStack(spacing: 0) { + // Breadcrumb header + breadcrumbHeader + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + // File list + if panel.isLoadingFiles { + Spacer() + ProgressView() + .scaleEffect(0.8) + Spacer() + } else if panel.files.isEmpty { + Spacer() + VStack(spacing: 8) { + Image(systemName: "folder") + .font(.title2) + .foregroundStyle(.secondary) + Text("空文件夹") + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + } else { + fileListView + } + + // Status bar + statusBar + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(Color(nsColor: .controlBackgroundColor)) + } + .background(Color(nsColor: .windowBackgroundColor)) + .overlay { + if isDropTargeted { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.orange, style: StrokeStyle(lineWidth: 2, dash: [6, 4])) + .fill(Color.orange.opacity(0.08)) + .padding(4) + .allowsHitTesting(false) + } + } + .overlay { + if panel.isMovingFiles { + ZStack { + Color.black.opacity(0.12) + Label("正在移动文件…", systemImage: "arrow.left.arrow.right") + .font(.callout.weight(.semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.regularMaterial, in: Capsule()) + } + .allowsHitTesting(true) + } + } + .onDrop(of: [.utf8PlainText], isTargeted: $isDropTargeted, perform: receivePanelDrop) + .onChange(of: showFileDetail) { _, file in + if let file { + PlayerWindowCoordinator.shared.open(model: model, file: file) + showFileDetail = nil + } + } + } + + private var breadcrumbHeader: some View { + HStack(spacing: 6) { + Button { + Task { await panel.navigateToRoot() } + } label: { + Image(systemName: "house.fill") + .font(.system(size: 11, weight: .bold)) + } + .buttonStyle(.plain) + .foregroundStyle(.orange) + .help("返回根目录") + + ForEach(Array(panel.folderPath.enumerated()), id: \.element.id) { index, folder in + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.tertiary) + Button(folder.name) { + Task { await panel.navigateToFolder(at: index) } + } + .buttonStyle(.plain) + .font(.caption.weight(index == panel.folderPath.count - 1 ? .bold : .medium)) + .foregroundStyle(index == panel.folderPath.count - 1 ? .primary : .secondary) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + index == panel.folderPath.count - 1 + ? Color.orange.opacity(0.12) + : Color.clear, + in: Capsule() + ) + } + + if panel.folderPath.isEmpty { + Text("全部文件") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + Task { await panel.loadFiles(folderID: panel.currentFolderID) } + } label: { + if panel.isLoadingFiles { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .semibold)) + } + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .disabled(panel.isLoadingFiles) + .help("刷新此面板") + } + } + + private var fileListView: some View { + List(selection: $panel.selectedFileIDs) { + ForEach(panel.files) { file in + PanelBrowserRow(file: file, panel: counterpart) + .tag(file.id) + .onTapGesture { + panel.selectedFileIDs = [file.id] + } + .onTapGesture(count: 2) { + if file.isDirectory { + Task { await panel.openFolder(file) } + } else { + showFileDetail = file + } + } + .contextMenu { + Button("在 Finder 中打开") { + Task { await openInFinder(file) } + } + if file.isDirectory { + Button("打开文件夹") { + Task { await panel.openFolder(file) } + } + } + Divider() + Button("移动到 \(counterpart.id == 0 ? "左" : "右")面板") { + Task { await moveFileToCounterpart(file) } + } + } + .onDrag { NSItemProvider(object: file.id as NSString) } + } + } + .listStyle(.plain) + .refreshable { + await panel.loadFiles(folderID: panel.currentFolderID) + } + } + + private var statusBar: some View { + HStack(spacing: 12) { + Label("文件 \(panel.files.filter { !$0.isDirectory }.count)", systemImage: "doc.fill") + .foregroundStyle(.secondary) + Label("文件夹 \(panel.files.filter(\.isDirectory).count)", systemImage: "folder.fill") + .foregroundStyle(.secondary) + if panel.isLoadingFiles { + HStack(spacing: 6) { + ProgressView().controlSize(.mini) + Text("加载中…") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + .font(.caption2) + } + + // MARK: - Actions + + private func openInFinder(_ file: CloudFile) async { + // Placeholder - would need actual implementation + print("Open in Finder: \(file.name)") + } + + private func moveFileToCounterpart(_ file: CloudFile) async { + await moveFiles(ids: [file.id], to: counterpart.currentFolderID) + } + + private func moveFiles(ids: [String], to targetFolderID: String?) async { + panel.isMovingFiles = true + counterpart.isMovingFiles = true + defer { + panel.isMovingFiles = false + counterpart.isMovingFiles = false + } + _ = try? await panel.api.fsMove(fileIDs: ids, parentID: targetFolderID) + // Refresh source and destination together after the move request ends. + async let currentRefresh: Void = panel.loadFiles(folderID: panel.currentFolderID) + async let counterpartRefresh: Void = counterpart.loadFiles(folderID: counterpart.currentFolderID) + _ = await (currentRefresh, counterpartRefresh) + } + + private func receivePanelDrop(_ providers: [NSItemProvider]) -> Bool { + let validProviders = providers.filter { $0.canLoadObject(ofClass: NSString.self) } + guard !validProviders.isEmpty else { return false } + let targetFileIDs = Set(panel.files.map(\.id)) + for provider in validProviders { + _ = provider.loadObject(ofClass: NSString.self) { value, _ in + guard let value = value as? NSString else { return } + let id = value as String + // Dropping an item back into its own currently opened folder is + // a no-op; only an item from the other panel is moved. + guard !targetFileIDs.contains(id) else { return } + Task { @MainActor in await moveFiles(ids: [id], to: panel.currentFolderID) } + } + } + return true + } +} + +// MARK: - Panel Browser Row + +struct PanelBrowserRow: View { + let file: CloudFile + let panel: PanelState + + var body: some View { + HStack(spacing: 12) { + // Icon + Image(systemName: fileIcon) + .font(.title3) + .foregroundStyle(file.isDirectory ? .orange : .blue) + .frame(width: 24) + + // File info + VStack(alignment: .leading, spacing: 2) { + Text(file.name) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + if !file.modifiedAt.isEmpty { + Text(file.modifiedAt) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + Spacer() + + // Size + if let size = file.size { + Text(ByteCountFormatter.string(fromByteCount: size, countStyle: .file)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + } + + private var fileIcon: String { + if file.isDirectory { return "folder.fill" } + return "doc.fill" + } +} + +// MARK: - Dual Panel Action Bar (replicates FilesBrowser's action bar) + +struct DualPanelActionBar: View { + let model: AppModel + @Binding var isDualPanel: Bool + @Binding var isImporting: Bool + @Binding var isCreatingFolder: Bool + let onOpenTool: (WorkspaceTool) -> Void + @State private var isRefreshing = false + + var body: some View { + HStack(spacing: 8) { + // Dual panel toggle + Button { + withAnimation(.easeInOut(duration: 0.2)) { isDualPanel.toggle() } + } label: { + Image(systemName: isDualPanel ? "rectangle.split.2x1" : "rectangle.on.rectangle") + } + .buttonStyle(.bordered) + .help(isDualPanel ? "切换到单面板" : "左右双面板浏览") + + Spacer() + + // Upload + Button { isImporting = true } label: { + Label("上传", systemImage: "arrow.up") + } + .buttonStyle(.borderedProminent) + .tint(.orange) + + // New folder + Button { isCreatingFolder = true } label: { + Image(systemName: "folder.badge.plus") + } + .buttonStyle(.bordered) + .help("新建文件夹") + + // Refresh + Button { + Task { await refresh() } + } label: { + Image(systemName: isRefreshing ? "arrow.triangle.2.circlepath" : "arrow.clockwise") + } + .buttonStyle(.bordered) + .disabled(isRefreshing) + .help("刷新") + + // More menu + Menu { + ForEach(WorkspaceTool.allCases) { tool in + Button(tool.displayName) { onOpenTool(tool) } + } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .frame(width: 28, height: 28) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial) + Divider() + } + + private func refresh() async { + isRefreshing = true + defer { isRefreshing = false } + await model.refresh() + } +} + +// MARK: - Helper Extensions + +extension WorkspaceTool { + var displayName: String { + switch self { + case .media: return "影视库" + case .scan: return "文件扫描与清理" + case .rename: return "批量重命名" + case .fastTransfer: return "秒传工具" + case .tmdb: return "TMDB 整理" + case .settings: return "工作区设置" + } + } +} + +// MARK: - Cross-panel Drop Coordinator + +@MainActor +final class DualPanelDropCoordinator: ObservableObject { + let leftPanel: PanelState + let rightPanel: PanelState + private let api: GuangyaAPI + + init(leftPanel: PanelState, rightPanel: PanelState, api: GuangyaAPI) { + self.leftPanel = leftPanel + self.rightPanel = rightPanel + self.api = api + } + + func handleDrop(from source: PanelState, files: [CloudFile], to targetFolderID: String?) async { + let destination = source === leftPanel ? rightPanel : leftPanel + source.isMovingFiles = true + destination.isMovingFiles = true + defer { + source.isMovingFiles = false + destination.isMovingFiles = false + } + do { + _ = try await api.fsMove(fileIDs: files.map(\.id), parentID: targetFolderID) + } catch { + print("⚠️ Move failed: \(error)") + } + async let sourceRefresh: Void = source.loadFiles(folderID: source.currentFolderID) + async let destinationRefresh: Void = destination.loadFiles(folderID: destination.currentFolderID) + _ = await (sourceRefresh, destinationRefresh) + } +} diff --git a/guangya_mac/GuangyaAPI.swift b/guangya_mac/GuangyaAPI.swift index b542548..65efda8 100644 --- a/guangya_mac/GuangyaAPI.swift +++ b/guangya_mac/GuangyaAPI.swift @@ -49,7 +49,10 @@ final class GuangyaAPI: @unchecked Sendable { configuration.timeoutIntervalForResource = 120 configuration.waitsForConnectivity = true configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - configuration.httpMaximumConnectionsPerHost = 8 + configuration.httpMaximumConnectionsPerHost = 20 + // Keep the app's long-lived API session eligible for HTTP/2 connection + // reuse while seconds-transfer sends its request sequence. + configuration.httpShouldUsePipelining = true configuration.httpAdditionalHeaders = [ "Accept": "application/json, text/plain, */*", "Content-Type": "application/json", @@ -221,7 +224,7 @@ final class GuangyaAPI: @unchecked Sendable { func fsCreateDir(name: String, parentID: String? = nil, failIfNameExist: Bool = false) async throws -> JSONValue { var body = ["dirName": JSONValue.string(name), "parentId": apiIDOrEmpty(parentID)] if failIfNameExist { body["failIfNameExist"] = .bool(true) } - return try await apiRequest("/nd.bizuserres.s/v1/file/create_dir", body: .object(body)) + return try await apiRequest("/nd.bizuserres.s/v1/file/create_dir", body: .object(body), allowedCodes: failIfNameExist ? [159] : []) } func fsCopy(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/copy_file", body: object(("fileIds", apiIDs(fileIDs)), ("parentId", apiIDOrEmpty(parentID)))) } func fsMove(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/move_file", body: object(("fileIds", apiIDs(fileIDs)), ("parentId", apiIDOrEmpty(parentID)))) } @@ -259,46 +262,45 @@ final class GuangyaAPI: @unchecked Sendable { func uploadToken(name: String, fileSize: Int64, parentID: String? = nil, md5: String? = nil) async throws -> JSONValue { var res: [String: JSONValue] = ["fileSize": .number(Double(fileSize))] - if let md5 { res["md5"] = .string(md5) } - return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(2), "name": .string(name), "res": .object(res), "parentId": apiIDOrEmpty(parentID)])) + if let md5 { res["md5"] = .string(Self.base64MD5(md5)) } + return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(2), "name": .string(name), "res": .object(res), "parentId": apiIDOrEmpty(parentID)]), allowedCodes: [156]) } - func checkCanFlashUpload(taskID: String, gcid: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/check_can_flash_upload", body: object(("taskId", taskID), ("gcid", gcid))) } - func uploadInfo(taskID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_info_by_task_id", body: object(("taskId", taskID))) } - func tmdbSearch(query: String, apiKey: String, mediaKind: TMDBMediaKind = .automatic, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue { + /// The web client's seconds-transfer workflow probes a hexadecimal MD5 with + /// capacity 1. A 156 business code means the file was linked immediately. + func flashTransferToken(name: String, fileSize: Int64, parentID: String? = nil, md5: String) async throws -> JSONValue { + let normalizedMD5 = md5.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let res: [String: JSONValue] = ["md5": .string(normalizedMD5), "fileSize": .number(Double(fileSize))] + return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(1), "name": .string(name), "res": .object(res), "parentId": apiIDOrEmpty(parentID)]), allowedCodes: [156]) + } + + /// Guangya GCID exports use the same capacity-1 instant-transfer endpoint + /// as MD5 exports, with gcid placed directly in res. + func flashTransferGCIDToken(name: String, fileSize: Int64, parentID: String? = nil, gcid: String) async throws -> JSONValue { + let normalizedGCID = gcid.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + let res: [String: JSONValue] = ["gcid": .string(normalizedGCID), "fileSize": .number(Double(fileSize))] + return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(1), "name": .string(name), "res": .object(res), "parentId": apiIDOrEmpty(parentID)]), allowedCodes: [156]) + } + + func deleteUploadTask(taskIDs: [String]) async throws -> JSONValue { + try await apiRequest("/nd.bizuserres.s/v1/file/delete_upload_task", body: object(("taskIds", taskIDs))) + } + + func checkCanFlashUpload(taskID: String, gcid: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/check_can_flash_upload", body: object(("taskId", taskID), ("gcid", gcid))) } + func uploadInfo(taskID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_info_by_task_id", body: object(("taskId", taskID)), allowedCodes: [145, 146, 155, 163]) } + + func tmdbSearch(query: String, apiKey: String, mediaKind: TMDBMediaKind = .automatic, proxyHost: String = "", proxyPort: String = "", year: Int? = nil) async throws -> JSONValue { let endpoint = mediaKind == .movie ? "movie" : (mediaKind == .tv ? "tv" : "multi") var components = URLComponents(string: "https://api.themoviedb.org/3/search/\(endpoint)")! components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "query", value: query), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "region", value: "CN"), URLQueryItem(name: "include_adult", value: "false")] - var request = URLRequest(url: components.url!) - request.httpMethod = "GET" - request.setValue("application/json", forHTTPHeaderField: "Accept") - let configuration = URLSessionConfiguration.ephemeral - // Cloud listing/detail calls can be slow on congested networks. Let the - // system wait for a route and retry transient failures below instead of - // surfacing the default 15-second timeout to the user. - configuration.timeoutIntervalForRequest = 45 - configuration.timeoutIntervalForResource = 120 - configuration.waitsForConnectivity = true - configuration.requestCachePolicy = .reloadIgnoringLocalCacheData - configuration.httpMaximumConnectionsPerHost = 8 - let normalizedHost = proxyHost - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "^https?://", with: "", options: .regularExpression) - .trimmingCharacters(in: CharacterSet(charactersIn: "/")) - if !normalizedHost.isEmpty, let port = Int(proxyPort), (1...65535).contains(port) { - configuration.connectionProxyDictionary = [ - kCFNetworkProxiesHTTPEnable as String: true, - kCFNetworkProxiesHTTPProxy as String: normalizedHost, - kCFNetworkProxiesHTTPPort as String: port, - kCFNetworkProxiesHTTPSEnable as String: true, - kCFNetworkProxiesHTTPSProxy as String: normalizedHost, - kCFNetworkProxiesHTTPSPort as String: port - ] + if let year { + switch mediaKind { + case .movie: components.queryItems?.append(URLQueryItem(name: "primary_release_year", value: String(year))) + case .tv: components.queryItems?.append(URLQueryItem(name: "first_air_date_year", value: String(year))) + case .automatic: break + } } - let tmdbSession = URLSession(configuration: configuration) - let (data, response) = try await tmdbSession.data(for: request) - guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "TMDB 请求失败") } - return try JSONDecoder().decode(JSONValue.self, from: data) + return try await tmdbJSONRequest(components.url!, proxyHost: proxyHost, proxyPort: proxyPort) } func tmdbDetails(id: Int, mediaKind: TMDBMediaKind, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue { @@ -326,12 +328,31 @@ final class GuangyaAPI: @unchecked Sendable { private func tmdbDataRequest(_ url: URL, proxyHost: String, proxyPort: String) async throws -> Data { var request = URLRequest(url: url); request.httpMethod = "GET"; request.timeoutInterval = 45; request.setValue("application/json", forHTTPHeaderField: "Accept") - let config = URLSessionConfiguration.ephemeral; config.timeoutIntervalForRequest = 45; config.timeoutIntervalForResource = 120 let host = proxyHost.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "^https?://", with: "", options: .regularExpression).trimmingCharacters(in: CharacterSet(charactersIn: "/")) - if !host.isEmpty, let port = Int(proxyPort), (1...65535).contains(port) { config.connectionProxyDictionary = [kCFNetworkProxiesHTTPEnable as String: true, kCFNetworkProxiesHTTPProxy as String: host, kCFNetworkProxiesHTTPPort as String: port, kCFNetworkProxiesHTTPSEnable as String: true, kCFNetworkProxiesHTTPSProxy as String: host, kCFNetworkProxiesHTTPSPort as String: port] } - let (data, response) = try await URLSession(configuration: config).data(for: request) - guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "TMDB 请求失败") } - return data + let port = Int(proxyPort).flatMap { (1...65535).contains($0) ? $0 : nil } + func fetch(useProxy: Bool) async throws -> Data { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 45; config.timeoutIntervalForResource = 120 + config.waitsForConnectivity = true; config.requestCachePolicy = .reloadIgnoringLocalCacheData + if useProxy, let port { + config.connectionProxyDictionary = [kCFNetworkProxiesHTTPEnable as String: true, kCFNetworkProxiesHTTPProxy as String: host, kCFNetworkProxiesHTTPPort as String: port, kCFNetworkProxiesHTTPSEnable as String: true, kCFNetworkProxiesHTTPSProxy as String: host, kCFNetworkProxiesHTTPSPort as String: port] + } + let (data, response) = try await URLSession(configuration: config).data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "TMDB 请求失败") } + return data + } + guard !host.isEmpty, port != nil else { return try await fetch(useProxy: false) } + do { return try await fetch(useProxy: true) } + catch { + // Some local HTTP proxies intermittently fail the CONNECT/TLS tunnel. + // TMDB is safe to retry directly when that specific transport fails. + let message = error.localizedDescription.lowercased() + let nsError = error as NSError + let isTLSFailure = nsError.domain == NSURLErrorDomain && [NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateHasBadDate, NSURLErrorServerCertificateUntrusted, NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateNotYetValid].contains(nsError.code) + || message.contains("tls") || message.contains("secure connection") + guard isTLSFailure else { throw error } + return try await fetch(useProxy: false) + } } func fileUpload(url: URL, parentID: String? = nil, contentType: String = "application/octet-stream", chunkSize: Int = 5 * 1024 * 1024) async throws -> JSONValue { @@ -360,18 +381,18 @@ final class GuangyaAPI: @unchecked Sendable { return try await request(Self.accountBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, refreshOnUnauthorized: false) } - private func apiRequest(_ path: String, method: String = "POST", body: JSONValue? = nil, multipart: [(String, String, String, Data)]? = nil) async throws -> JSONValue { + private func apiRequest(_ path: String, method: String = "POST", body: JSONValue? = nil, multipart: [(String, String, String, Data)]? = nil, allowedCodes: Set = []) async throws -> JSONValue { if let expiry = tokenExpiresAt, Date() >= expiry, refreshTokenValue != nil { try await refreshOrThrowAuthorizationExpired() } var headers = ["traceparent": Self.generateTraceparent()] if !accessToken.isEmpty { headers["authorization"] = "Bearer \(accessToken)" } - return try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, multipart: multipart) + return try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, multipart: multipart, allowedCodes: allowedCodes) } private func publicPost(_ path: String, body: JSONValue) async throws -> JSONValue { try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: "POST", body: body, headers: ["traceparent": Self.generateTraceparent()], refreshOnUnauthorized: false) } - private func request(_ url: URL, method: String, body: JSONValue?, headers: [String: String], multipart: [(String, String, String, Data)]? = nil, refreshOnUnauthorized: Bool = true, retryCount: Int = 2) async throws -> JSONValue { + private func request(_ url: URL, method: String, body: JSONValue?, headers: [String: String], multipart: [(String, String, String, Data)]? = nil, refreshOnUnauthorized: Bool = true, retryCount: Int = 2, allowedCodes: Set = []) async throws -> JSONValue { var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = multipart == nil ? 45 : 120 @@ -388,7 +409,7 @@ final class GuangyaAPI: @unchecked Sendable { } catch { guard retryCount > 0, Self.isTransientNetworkError(error) else { throw error } try await Task.sleep(for: .seconds(Double(3 - retryCount) * 0.9 + Double.random(in: 0.15...0.55))) - return try await self.request(url, method: method, body: body, headers: headers, multipart: multipart, refreshOnUnauthorized: refreshOnUnauthorized, retryCount: retryCount - 1) + return try await self.request(url, method: method, body: body, headers: headers, multipart: multipart, refreshOnUnauthorized: refreshOnUnauthorized, retryCount: retryCount - 1, allowedCodes: allowedCodes) } guard let http = response as? HTTPURLResponse else { throw GuangyaAPIError.invalidResponse } if http.statusCode == 401, refreshOnUnauthorized { @@ -400,13 +421,13 @@ final class GuangyaAPI: @unchecked Sendable { // Transport failures and temporary account-service errors remain visible. throw error } - return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false) + return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false, allowedCodes: allowedCodes) } guard (200..<300).contains(http.statusCode) else { if retryCount > 0, [408, 429, 500, 502, 503, 504].contains(http.statusCode) { let retryAfter = Double(http.value(forHTTPHeaderField: "Retry-After") ?? "") ?? Double(3 - retryCount) try await Task.sleep(for: .seconds(max(0.8, min(retryAfter + Double.random(in: 0.1...0.5), 15)))) - return try await self.request(url, method: method, body: body, headers: headers, multipart: multipart, refreshOnUnauthorized: refreshOnUnauthorized, retryCount: retryCount - 1) + return try await self.request(url, method: method, body: body, headers: headers, multipart: multipart, refreshOnUnauthorized: refreshOnUnauthorized, retryCount: retryCount - 1, allowedCodes: allowedCodes) } let message: String if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) { @@ -418,17 +439,19 @@ final class GuangyaAPI: @unchecked Sendable { } if data.isEmpty { return .object([:]) } if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) { - if let code = decoded.firstIntDeep(["code"]), code != 0 { + if let code = decoded.firstIntDeep(["code"]), code != 0, !allowedCodes.contains(code) { // Some API gateways put an expired-token 401 in the JSON body // while keeping the HTTP response status at 200. - if code == 401, refreshOnUnauthorized { + // The API uses 117 for an invalid or expired bearer token on + // some resource endpoints, while other endpoints use 401. + if [401, 117].contains(code), refreshOnUnauthorized { guard refreshTokenValue != nil else { throw GuangyaAPIError.authorizationExpired } do { try await refreshOrThrowAuthorizationExpired() } catch { throw error } - return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false) + return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false, allowedCodes: allowedCodes) } throw GuangyaAPIError.http(status: code, message: decoded.firstStringDeep(["msg", "message"]) ?? "接口返回错误") } @@ -515,4 +538,18 @@ final class GuangyaAPI: @unchecked Sendable { private static func generateTraceparent() -> String { "00-\(randomHex(16))-\(randomHex(8))-01" } private static func httpDate() -> String { let formatter = DateFormatter(); formatter.locale = Locale(identifier: "en_US_POSIX"); formatter.timeZone = TimeZone(secondsFromGMT: 0); formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"; return formatter.string(from: Date()) } private static func calculateGCID(data: Data) -> String { let chunkSize = data.count <= 0x8000000 ? 262144 : data.count <= 0x10000000 ? 524288 : data.count <= 0x20000000 ? 1048576 : 2097152; var hashes = Data(); var offset = 0; while offset < data.count { let end = min(offset + chunkSize, data.count); hashes.append(contentsOf: Insecure.SHA1.hash(data: data.subdata(in: offset.. String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.range(of: "^[A-Fa-f0-9]{32}$", options: .regularExpression) != nil else { return normalized } + var bytes = Data() + var index = normalized.startIndex + while index < normalized.endIndex { + let next = normalized.index(index, offsetBy: 2) + guard let byte = UInt8(normalized[index..? + @State private var selectedTaskID: UUID? + @State private var showTMDBSettings = false + @State private var detailItem: MediaLibraryItem? + @State private var manualRecognitionItem: MediaLibraryItem? + @State private var mediaSearchResults: [MediaLibraryItem] = [] + @State private var isLoadingMediaSearch = false + @State private var pendingLiveItemsByLibraryID: [String: [MediaLibraryItem]] = [:] + @State private var liveItemsRefreshTask: Task? + @State private var cachedItemsOffset = 0 + @State private var canLoadMoreCachedItems = false + @State private var isLoadingMoreCachedItems = false + + private let cachedItemsPageSize = 72 private var selectedLibrary: MediaLibraryDefinition? { model.mediaLibraries.first { $0.id == selectedLibraryID } } - private var activeProgress: MediaLibraryScanProgress? { - if model.isScanningMediaLibrary { return model.mediaLibraryScanProgress } - if model.isWritingMediaMetadata { return model.mediaMetadataWriteProgress } - return nil + private var currentLibraryTask: MediaLibraryTask? { + guard let selectedLibraryID else { return nil } + return model.mediaLibraryTasks.first { $0.libraryID == selectedLibraryID && $0.isActive } } + private var activeTaskCount: Int { model.mediaLibraryTasks.filter(\.isActive).count } + private var isShowingMediaSearchResults: Bool { !model.mediaLibrarySearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private var detailContextItems: [MediaLibraryItem] { isShowingMediaSearchResults ? mediaSearchResults : items } + private var visibleItems: [MediaLibraryItem] { let filtered = items.filter { item in - let matchesSearch = searchText.isEmpty || item.title.localizedCaseInsensitiveContains(searchText) || item.originalTitle.localizedCaseInsensitiveContains(searchText) + let matchesSearch = model.mediaLibrarySearchText.isEmpty || item.title.localizedCaseInsensitiveContains(model.mediaLibrarySearchText) || item.originalTitle.localizedCaseInsensitiveContains(model.mediaLibrarySearchText) let matchesFilter: Bool switch filter { case .all: matchesFilter = true @@ -81,7 +102,11 @@ struct MediaLibraryPage: View { private var activeCollection: MediaCollectionGroup? { collectionGroups.first { $0.id == activeCollectionID } } - private var posterColumns: [GridItem] { [GridItem(.adaptive(minimum: 132, maximum: 172), spacing: 16)] } + private var posterColumns: [GridItem] { [GridItem(.adaptive(minimum: 132, maximum: 166), spacing: 16)] } + + private var movieCount: Int { model.mediaLibraryStatistics.movies } + private var seriesCount: Int { model.mediaLibraryStatistics.series } + private var unmatchedCount: Int { model.mediaLibraryStatistics.unmatched } private var wallTitle: String { if let activeCollection { return activeCollection.name } @@ -96,24 +121,59 @@ struct MediaLibraryPage: View { var body: some View { ZStack { + // Keep the media workspace in the same material and brand language + // as the cloud workspace, while the artwork remains the visual focus. AppBackdrop() - if model.mediaLibraryDestination == .tasks { - mediaTasksView - } else if model.mediaLibraries.isEmpty && !isLoadingCache { - emptyLibraryState - } else { - VStack(spacing: 0) { - libraryControls - ScrollView { - libraryContent + HStack(spacing: 0) { + mediaSidebar + Divider().opacity(0.5) + ScrollViewReader { scrollProxy in + Group { + if let detailItem { + MediaItemDetailPage(model: model, item: detailItem, items: detailContextItems, preferredLibraryID: selectedLibraryID, onBack: { + self.detailItem = nil + DispatchQueue.main.async { + guard let selectedItemID else { return } + withAnimation(.easeInOut(duration: 0.18)) { scrollProxy.scrollTo("wall-\(selectedItemID)", anchor: .center) } + } + }, onPlay: { item in + play(item) + }, onScrape: { item in + scrapeUnmatchedItem(item) + }, onRecognized: { updated in + if let index = items.firstIndex(where: { $0.id == updated.id }) { items[index] = updated } + if let index = mediaSearchResults.firstIndex(where: { $0.id == updated.id }) { mediaSearchResults[index] = updated } + self.detailItem = updated + }) + } else if isShowingMediaSearchResults { + mediaSearchResultsPage + } else if model.mediaLibraryDestination == .tasks { + mediaTasksView + } else if model.mediaLibraryDestination == .management { + mediaLibraryManagementView + } else if model.mediaLibraryDestination == .categories { + MediaCategoryManagementPage(model: model) + } else if model.mediaLibraryDestination == .tmdb { + ScrollView { TMDBToolPage(model: model).padding(30) } + } else if model.mediaLibraries.isEmpty && !isLoadingCache { + emptyLibraryState + } else { + VStack(spacing: 0) { + libraryControls + ScrollView { + libraryContent + } + } } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } } .sheet(isPresented: $showCreateLibrary) { - CreateMediaLibrarySheet(model: model) { name, sources, kind, recursive in + CreateMediaLibrarySheet(model: model) { name, sources, kind, recursive, minimumSizeMB in Task { - let library = await model.createMediaLibrary(name: name, sources: sources, kind: kind, recursive: recursive) + let library = await model.createMediaLibrary(name: name, sources: sources, kind: kind, recursive: recursive, minimumSizeMB: minimumSizeMB) model.mediaLibraryDestination = .library(library.id) selectedLibraryID = library.id startScan(library) @@ -121,14 +181,23 @@ struct MediaLibraryPage: View { } } .sheet(item: $editingLibrary) { library in - CreateMediaLibrarySheet(model: model, initialLibrary: library) { name, sources, kind, recursive in + CreateMediaLibrarySheet(model: model, initialLibrary: library) { name, sources, kind, recursive, minimumSizeMB in Task { - let updated = MediaLibraryDefinition(id: library.id, name: name, sources: sources, kind: kind, recursive: recursive, updatedAt: library.updatedAt) + let updated = MediaLibraryDefinition(id: library.id, name: name, sources: sources, kind: kind, recursive: recursive, minimumSizeMB: minimumSizeMB, updatedAt: library.updatedAt) await model.updateMediaLibrary(updated) startScan(updated) } } } + .sheet(isPresented: $showTMDBSettings) { + MediaTMDBSettingsSheet(model: model) + } + .sheet(item: $manualRecognitionItem) { item in + ManualMediaRecognitionSheet(model: model, item: item, preferredLibraryID: selectedLibraryID) { updated in + if let index = items.firstIndex(where: { $0.id == updated.id }) { items[index] = updated } + if detailItem?.id == updated.id { detailItem = updated } + } + } .confirmationDialog("删除影视库?", isPresented: $showDeleteConfirmation) { Button("删除影视库", role: .destructive) { guard let library = selectedLibrary else { return } @@ -142,21 +211,236 @@ struct MediaLibraryPage: View { } message: { Text("只删除本机影视库配置和刮削缓存,不会删除云盘文件。") } - .task { await initializeLibrary() } + .task { + await initializeLibrary() + await loadMediaSearchResults() + } .onChange(of: selectedLibraryID) { _, _ in activeCollectionID = nil; Task { await loadCachedItems() } } - .onChange(of: model.mediaLibraryDestination) { _, _ in Task { await loadDestination() } } + .onChange(of: model.mediaLibraryDestination) { _, _ in detailItem = nil; Task { await loadDestination() } } .onChange(of: filter) { _, value in if value != .collections { activeCollectionID = nil } } .onChange(of: model.mediaLibraryTarget?.id) { _, _ in Task { await initializeLibrary() } } - .onReceive(model.$mediaLibraryLiveItems) { liveItems in - if model.mediaLibraryDestination == .all { - var merged = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) - liveItems.forEach { merged[$0.id] = $0 } - items = merged.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } - } else { - guard model.mediaLibraryLiveLibraryID == selectedLibraryID else { return } - items = liveItems + .onChange(of: model.mediaLibrarySearchText) { _, _ in Task { await loadMediaSearchResults() } } + .background { + GeometryReader { proxy in + Color.clear + .onAppear { isCompactSidebar = proxy.size.width < 1120 } + .onChange(of: proxy.size.width) { _, width in isCompactSidebar = width < 1120 } } - if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id } + } + .onReceive(model.$mediaLibraryLiveItemsByLibraryID) { liveItemsByLibraryID in + for (libraryID, liveItems) in liveItemsByLibraryID { + libraryVisualItems[libraryID] = liveItems + } + pendingLiveItemsByLibraryID = liveItemsByLibraryID + scheduleLiveItemsRefresh(immediately: items.isEmpty) + } + } + + private func scheduleLiveItemsRefresh(immediately: Bool) { + liveItemsRefreshTask?.cancel() + guard !immediately else { + applyPendingLiveItems() + return + } + liveItemsRefreshTask = Task { + try? await Task.sleep(for: .milliseconds(180)) + guard !Task.isCancelled else { return } + applyPendingLiveItems() + } + } + + /// Scans can yield several results per second. Coalescing wall updates keeps + /// scrolling and sidebar interaction responsive, while the first batch still + /// appears as soon as it has been scraped. + private func applyPendingLiveItems() { + let liveItemsByLibraryID = pendingLiveItemsByLibraryID + if model.mediaLibraryDestination == .all { + var merged = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) + liveItemsByLibraryID.values.lazy.flatMap { $0 }.forEach { merged[$0.id] = $0 } + items = merged.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } else { + guard let selectedLibraryID, let liveItems = liveItemsByLibraryID[selectedLibraryID] else { return } + items = liveItems.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } + if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id } + } + + private var mediaSidebar: some View { + Group { + if isCompactSidebar { compactMediaSidebar } + else { fullMediaSidebar } + } + } + + private var fullMediaSidebar: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 11) { + Image(systemName: "play.tv.fill") + .font(.headline.weight(.bold)).foregroundStyle(.white) + .frame(width: 38, height: 38) + .background(AppTheme.orange, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + VStack(alignment: .leading, spacing: 1) { + Text("光鸭影视").font(.headline.weight(.bold)) + Text("MEDIA CENTER").font(.system(size: 9, weight: .bold, design: .rounded)).tracking(1.1).foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal, 16).padding(.top, 18).padding(.bottom, 18) + + VStack(spacing: 3) { + mediaNavigationRow(title: "首页", icon: "house.fill", isSelected: model.mediaLibraryDestination == .all && filter == .all) { + model.mediaLibraryDestination = .all; filter = .all; activeCollectionID = nil + } + mediaNavigationRow(title: "电影", icon: "film", isSelected: model.mediaLibraryDestination == .all && filter == .movies, badge: movieCount == 0 ? nil : "\(movieCount)") { + model.mediaLibraryDestination = .all; filter = .movies; activeCollectionID = nil + } + mediaNavigationRow(title: "电视剧", icon: "play.tv", isSelected: model.mediaLibraryDestination == .all && filter == .series, badge: seriesCount == 0 ? nil : "\(seriesCount)") { + model.mediaLibraryDestination = .all; filter = .series; activeCollectionID = nil + } + mediaNavigationRow(title: "合集", icon: "rectangle.stack.fill", isSelected: model.mediaLibraryDestination == .all && filter == .collections, badge: model.mediaLibraryStatistics.collections == 0 ? nil : "\(model.mediaLibraryStatistics.collections)") { + model.mediaLibraryDestination = .all; filter = .collections; activeCollectionID = nil + } + mediaNavigationRow(title: "未识别", icon: "questionmark.folder", isSelected: model.mediaLibraryDestination == .all && filter == .unmatched, badge: unmatchedCount == 0 ? nil : "\(unmatchedCount)") { + model.mediaLibraryDestination = .all; filter = .unmatched; activeCollectionID = nil + } + } + .padding(.horizontal, 6) + + Text("媒体库").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + .padding(.horizontal, 16).padding(.top, 22).padding(.bottom, 8) + ScrollView { + VStack(spacing: 3) { + ForEach(model.mediaLibraries) { library in + mediaNavigationRow(title: library.name, icon: library.kind.icon, isSelected: model.mediaLibraryDestination == .library(library.id), badge: libraryItemCount(library.id) == 0 ? nil : "\(libraryItemCount(library.id))") { + model.mediaLibraryDestination = .library(library.id); filter = .all; activeCollectionID = nil + } + } + } + .padding(.horizontal, 6) + } + Text("管理").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + .padding(.horizontal, 16).padding(.top, 18).padding(.bottom, 8) + VStack(spacing: 3) { + mediaNavigationRow(title: "管理", icon: "slider.horizontal.3", isSelected: model.mediaLibraryDestination == .management) { + model.mediaLibraryDestination = .management + } + mediaNavigationRow(title: "刮削任务", icon: activeTaskCount > 0 ? "arrow.triangle.2.circlepath" : "checklist", isSelected: model.mediaLibraryDestination == .tasks, badge: activeTaskCount == 0 ? nil : "\(activeTaskCount)") { + model.mediaLibraryDestination = .tasks + } + mediaNavigationRow(title: "TMDB 整理", icon: "wand.and.stars", isSelected: model.mediaLibraryDestination == .tmdb) { + model.mediaLibraryDestination = .tmdb + } + mediaNavigationRow(title: "分类管理", icon: "square.grid.2x2", isSelected: model.mediaLibraryDestination == .categories) { + model.mediaLibraryDestination = .categories + } + } + .padding(.horizontal, 6) + Divider().opacity(0.5) + mediaAccountFooter + } + .frame(width: 248) + .background(.regularMaterial) + } + + private var compactMediaSidebar: some View { + VStack(spacing: 10) { + Image(systemName: "play.tv.fill").font(.headline).foregroundStyle(.white) + .frame(width: 36, height: 36).background(AppTheme.orange, in: RoundedRectangle(cornerRadius: 9)) + .help("光鸭影视") + Divider() + mediaCompactButton("首页", icon: "house.fill", selected: model.mediaLibraryDestination == .all && filter == .all) { model.mediaLibraryDestination = .all; filter = .all } + mediaCompactButton("电影", icon: "film", selected: model.mediaLibraryDestination == .all && filter == .movies) { model.mediaLibraryDestination = .all; filter = .movies } + mediaCompactButton("电视剧", icon: "play.tv", selected: model.mediaLibraryDestination == .all && filter == .series) { model.mediaLibraryDestination = .all; filter = .series } + mediaCompactButton("合集", icon: "rectangle.stack.fill", selected: model.mediaLibraryDestination == .all && filter == .collections) { model.mediaLibraryDestination = .all; filter = .collections } + mediaCompactButton("未识别", icon: "questionmark.folder", selected: model.mediaLibraryDestination == .all && filter == .unmatched) { model.mediaLibraryDestination = .all; filter = .unmatched } + Divider().padding(.vertical, 2) + ForEach(model.mediaLibraries) { library in + mediaCompactButton(library.name, icon: library.kind.icon, selected: model.mediaLibraryDestination == .library(library.id)) { model.mediaLibraryDestination = .library(library.id); filter = .all } + } + Spacer(minLength: 8) + Divider() + mediaCompactButton("管理", icon: "slider.horizontal.3", selected: model.mediaLibraryDestination == .management) { model.mediaLibraryDestination = .management } + mediaCompactButton("刮削任务", icon: "checklist", selected: model.mediaLibraryDestination == .tasks) { model.mediaLibraryDestination = .tasks } + mediaCompactButton("TMDB 整理", icon: "wand.and.stars", selected: model.mediaLibraryDestination == .tmdb) { model.mediaLibraryDestination = .tmdb } + mediaCompactButton("分类管理", icon: "square.grid.2x2", selected: model.mediaLibraryDestination == .categories) { model.mediaLibraryDestination = .categories } + Menu { + Text(model.user.name) + if !model.user.phone.isEmpty { Text(model.user.phone) } + Divider() + Button("退出登录", role: .destructive) { model.logout() } + } label: { + Text(String(model.user.name.prefix(1))).font(.caption.weight(.bold)).foregroundStyle(AppTheme.orange) + .frame(width: 32, height: 32).background(AppTheme.orange.opacity(0.14), in: Circle()) + } + .menuStyle(.borderlessButton).help("账户:\(model.user.name)") + } + .padding(.vertical, 14).frame(width: 64).frame(maxHeight: .infinity) + .background(.regularMaterial) + } + + private func mediaCompactButton(_ title: String, icon: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon).frame(width: 34, height: 34) + .foregroundStyle(selected ? AppTheme.orange : Color.secondary) + .background { mediaSelectionSurface(selected, cornerRadius: 7) } + } + .buttonStyle(.plain).help(title).compactHoverHint(title).accessibilityLabel(title) + } + + private var mediaAccountFooter: some View { + Menu { + Text(model.user.name) + if !model.user.phone.isEmpty { Text(model.user.phone) } + if model.user.capacity != nil { Text(model.user.capacityText) } + Divider() + Button("退出登录", role: .destructive) { model.logout() } + } label: { + HStack(spacing: 10) { + Text(String(model.user.name.prefix(1))).font(.callout.weight(.bold)).foregroundStyle(AppTheme.orange) + .frame(width: 32, height: 32).background(AppTheme.orange.opacity(0.14), in: Circle()) + VStack(alignment: .leading, spacing: 1) { + Text(model.user.name).font(.callout.weight(.semibold)).lineLimit(1) + Text(model.user.phone.isEmpty ? "账户信息" : model.user.phone).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Image(systemName: "chevron.up.chevron.down").font(.caption).foregroundStyle(.tertiary) + } + .padding(12).contentShape(Rectangle()) + } + .menuStyle(.borderlessButton).padding(10) + } + + private func mediaNavigationRow(title: String, icon: String, isSelected: Bool, badge: String? = nil, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 13, weight: .semibold)) + .frame(width: 27, height: 27) + .foregroundStyle(isSelected ? Color.white : AppTheme.orange) + .background(isSelected ? AppTheme.orange : AppTheme.orange.opacity(0.11), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + Text(title).font(.callout.weight(isSelected ? .semibold : .regular)).lineLimit(1) + Spacer(minLength: 4) + if let badge { Text(badge).font(.caption2.monospacedDigit()).foregroundStyle(isSelected ? AppTheme.orange : .secondary) } + } + .padding(.horizontal, 9).frame(height: 42) + .background { mediaSelectionSurface(isSelected, cornerRadius: 12) } + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func mediaSelectionSurface(_ isSelected: Bool, cornerRadius: CGFloat) -> some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + if isSelected { + if #available(macOS 26.0, *) { + AppTheme.orange.opacity(0.14) + .glassEffect(.regular.tint(AppTheme.orange.opacity(0.23)), in: shape) + } else { + AppTheme.orange.opacity(0.14) + .background(.ultraThinMaterial, in: shape) + } + } else { + Color.clear } } @@ -172,112 +456,214 @@ struct MediaLibraryPage: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private var mediaTasksView: some View { - VStack(alignment: .leading, spacing: 18) { - HStack { + private var mediaLibraryManagementView: some View { + VStack(spacing: 0) { + HStack(alignment: .center) { VStack(alignment: .leading, spacing: 4) { - Text("刮削任务").font(.title2.weight(.bold)) - Text("扫描、识别、入库与资源目录写回").font(.callout).foregroundStyle(.secondary) + Text("媒体库管理").font(.title2.weight(.bold)) + Text("管理媒体目录、扫描状态与刮削规则").font(.callout).foregroundStyle(.secondary) } Spacer() + if model.isMediaLibraryBackupRunning { + ProgressView().controlSize(.small) + } + Menu { + Button("导出本地备份…", action: exportMediaLibraryBackup) + Button("导入本地备份…", action: importMediaLibraryBackup) + Divider() + Button("备份到云盘") { Task { await model.backupMediaLibraryToCloud() } } + Button("从云盘恢复") { Task { await model.restoreMediaLibraryBackupFromCloud() } } + } label: { + Label("数据", systemImage: "externaldrive.badge.icloud") + } + .buttonStyle(.bordered) + Button { showTMDBSettings = true } label: { Label("TMDB 设置", systemImage: "key") } + .buttonStyle(.bordered) + Button { showCreateLibrary = true } label: { Label("新增媒体库", systemImage: "plus") } + .buttonStyle(.borderedProminent).tint(AppTheme.orange) + Menu { Button("名称") { } ; Button("最近扫描") { } } label: { Label("排序", systemImage: "arrow.up.arrow.down") } + .buttonStyle(.bordered) } - .padding(.horizontal, 28).padding(.top, 26) - if let progress = activeProgress { - VStack(alignment: .leading, spacing: 14) { - HStack { - Label(progress.phase, systemImage: model.isScanningMediaLibrary ? "magnifyingglass" : "square.and.arrow.down") - .font(.headline) - Spacer() - Text("\(progress.completed)/\(progress.total)").font(.callout.monospacedDigit()).foregroundStyle(.secondary) - } - ProgressView(value: Double(progress.completed), total: Double(max(1, progress.total))).tint(AppTheme.orange) - HStack { - Text(model.mediaLibraryLiveLibraryID.flatMap { id in model.mediaLibraries.first(where: { $0.id == id })?.name } ?? "影视库") - .font(.caption).foregroundStyle(.secondary) - Spacer() - Button(role: .destructive) { - if model.isScanningMediaLibrary { cancelScan() } else { model.cancelMediaMetadataWriteback() } - } label: { Label("停止", systemImage: "stop.fill") }.buttonStyle(.bordered) + .padding(.horizontal, 30).padding(.vertical, 20) + if !model.mediaLibraryBackupStatus.isEmpty { + HStack(spacing: 7) { + Image(systemName: model.isMediaLibraryBackupRunning ? "arrow.triangle.2.circlepath" : "externaldrive") + Text(model.mediaLibraryBackupStatus) + .lineLimit(1) + Spacer() + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 30).padding(.bottom, 10) + } + Divider() + if model.mediaLibraries.isEmpty { + ContentUnavailableView("还没有媒体库", systemImage: "rectangle.stack.badge.plus", description: Text("添加一个或多个云端媒体目录后开始刮削。")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + HStack { + Text("媒体库").frame(width: 255, alignment: .leading) + Text("媒体文件夹").frame(maxWidth: .infinity, alignment: .leading) + Text("类型").frame(width: 100, alignment: .leading) + Text("文件最近更新").frame(width: 130, alignment: .leading) + Text("操作").frame(width: 180, alignment: .trailing) + } + .font(.caption.weight(.semibold)).foregroundStyle(.secondary) + .padding(.horizontal, 30).frame(height: 46) + ForEach(model.mediaLibraries) { library in + MediaLibraryManagementRow( + library: library, + itemCount: libraryItemCount(library.id), + onOpen: { model.mediaLibraryDestination = .library(library.id) }, + onScan: { startScan(library) }, + onEdit: { editingLibrary = library }, + onDelete: { selectedLibraryID = library.id; showDeleteConfirmation = true } + ) + Divider().padding(.leading, 30) + } } } - .padding(18).softCard(radius: 8).padding(.horizontal, 28) - } else { - ContentUnavailableView("没有正在运行的任务", systemImage: "checkmark.circle", description: Text("从具体影视库页面启动扫描后,进度会显示在这里。")) - .frame(maxWidth: .infinity, maxHeight: .infinity) } - Spacer() + } + } + + private func exportMediaLibraryBackup() { + let panel = NSSavePanel() + panel.title = "导出影视缓存与刮削数据" + panel.nameFieldStringValue = "media-library.sqlite3" + panel.allowedContentTypes = [.database] + guard panel.runModal() == .OK, let url = panel.url else { return } + Task { + do { + model.mediaLibraryBackupStatus = "正在导出本地备份…" + try await model.exportMediaLibraryBackup(to: url) + model.mediaLibraryBackupStatus = "本地备份已导出" + } catch { + model.mediaLibraryBackupStatus = "导出失败:\(error.localizedDescription)" + } + } + } + + private func importMediaLibraryBackup() { + let panel = NSOpenPanel() + panel.title = "导入影视缓存与刮削数据" + panel.allowedContentTypes = [.database] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + guard panel.runModal() == .OK, let url = panel.url else { return } + let accessed = url.startAccessingSecurityScopedResource() + Task { + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + do { + model.mediaLibraryBackupStatus = "正在导入本地备份…" + try await model.importMediaLibraryBackup(from: url) + await initializeLibrary() + model.mediaLibraryBackupStatus = "本地备份已导入" + } catch { + model.mediaLibraryBackupStatus = "导入失败:\(error.localizedDescription)" + } + } + } + + private var mediaTasksView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 5) { + Text("刮削任务").font(.system(size: 26, weight: .bold)) + Text("每个影视库独立扫描、识别、入库和写回,可并行执行。") + .font(.callout).foregroundStyle(.secondary) + } + Spacer() + Label("\(activeTaskCount) 个进行中", systemImage: "arrow.triangle.2.circlepath") + .font(.callout.weight(.medium)).foregroundStyle(activeTaskCount == 0 ? Color.secondary : AppTheme.orange) + if model.mediaLibraryTasks.contains(where: { !$0.isActive }) { + Button("清理已结束") { model.clearFinishedMediaLibraryTasks() } + .buttonStyle(.bordered).controlSize(.small) + .help("删除已完成、已停止和失败任务的记录") + } + } + if model.tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Label("TMDB API Key 未配置,扫描到的视频无法自动识别。请在影视模式侧栏配置后重新扫描。", systemImage: "exclamationmark.triangle.fill") + .font(.callout).foregroundStyle(.yellow).padding(12) + .background(Color.yellow.opacity(0.10), in: RoundedRectangle(cornerRadius: 7)) + } + if model.mediaLibraryTasks.isEmpty { + ContentUnavailableView("没有刮削记录", systemImage: "checklist", description: Text("从任意影视库点击刷新即可创建并行任务。")) + .frame(maxWidth: .infinity).padding(.vertical, 90) + } else { + ForEach(model.mediaLibraryTasks) { task in + MediaLibraryTaskCard(task: task, isExpanded: selectedTaskID == task.id) { + selectedTaskID = selectedTaskID == task.id ? nil : task.id + } onCancel: { + model.cancelMediaLibraryTask(task.id) + } onResume: { + Task { await model.resumeMediaLibraryTask(task.id) } + } onRemove: { + if selectedTaskID == task.id { selectedTaskID = nil } + model.removeMediaLibraryTask(task.id) + } + } + } + } + .padding(.horizontal, 30).padding(.vertical, 26) + .frame(maxWidth: 1280, alignment: .leading) } } private var libraryControls: some View { - VStack(spacing: 14) { - HStack(spacing: 12) { + VStack(spacing: 10) { + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 3) { + Text(selectedLibrary?.name ?? (filter == .all ? "首页" : wallTitle)) + .font(.system(size: 24, weight: .bold)) + .lineLimit(1) + if let selectedLibrary { + Text("\(selectedLibrary.kind.title) · \(selectedLibrary.sources.count) 个媒体目录") + .font(.caption).foregroundStyle(.secondary) + } else if filter == .all { + Text("在你的云盘中浏览、整理和播放影视内容") + .font(.caption).foregroundStyle(.secondary) + } + } + Spacer(minLength: 24) + Button { showCreateLibrary = true } label: { Label("新建媒体库", systemImage: "plus") } + .buttonStyle(.borderedProminent).tint(AppTheme.orange).help("新建影视库") if let library = selectedLibrary { - Image(systemName: library.kind.icon).font(.title3.weight(.semibold)).foregroundStyle(.orange) - .frame(width: 38, height: 38).background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) - } - VStack(alignment: .leading, spacing: 2) { - Text(selectedLibrary?.name ?? "所有影视").font(.headline.weight(.semibold)).lineLimit(1) - Text(selectedLibrary.map { "\($0.kind.title) · \($0.sources.count) 个目录" } ?? "\(model.mediaLibraries.count) 个影视库") - .font(.caption).foregroundStyle(.secondary).lineLimit(1) - } - Spacer() - HStack(spacing: 7) { - Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField("搜索影视库", text: $searchText).textFieldStyle(.plain) - if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") }.buttonStyle(.plain).foregroundStyle(.secondary) } - } - .padding(.horizontal, 10).frame(width: 240, height: 34).background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 7)) - Button { showCreateLibrary = true } label: { Image(systemName: "plus") }.buttonStyle(.bordered).help("新建影视库") - Button { - if model.isScanningMediaLibrary { cancelScan() } - else if model.isWritingMediaMetadata { model.cancelMediaMetadataWriteback() } - else if let library = selectedLibrary { startScan(library) } - } label: { - Image(systemName: model.isScanningMediaLibrary || model.isWritingMediaMetadata ? "stop.fill" : "arrow.triangle.2.circlepath") - } - .buttonStyle(.bordered).tint(model.isScanningMediaLibrary || model.isWritingMediaMetadata ? .red : nil) - .help(model.isScanningMediaLibrary ? "取消扫描" : (model.isWritingMediaMetadata ? "停止写回" : "扫描并刮削")) - .disabled(selectedLibrary == nil) - Menu { - Button { editingLibrary = selectedLibrary } label: { Label("管理影视库", systemImage: "slider.horizontal.3") } - .disabled(selectedLibrary == nil) - Divider() - Button("删除影视库", role: .destructive) { showDeleteConfirmation = true } - .disabled(selectedLibrary == nil) - } label: { Image(systemName: "ellipsis.circle") } + Button { startScan(library) } label: { Image(systemName: "arrow.triangle.2.circlepath") } + .buttonStyle(.bordered).help("扫描并自动识别") + Menu { + Button { editingLibrary = library } label: { Label("管理影视库", systemImage: "slider.horizontal.3") } + Divider() + Button("删除影视库", role: .destructive) { showDeleteConfirmation = true } + } label: { Image(systemName: "ellipsis") } .menuStyle(.borderlessButton).help("影视库操作") + } } - if let progress = activeProgress { + if let task = currentLibraryTask { HStack(spacing: 10) { - ProgressView(value: Double(progress.completed), total: Double(max(1, progress.total))).frame(maxWidth: 260) - Text(progress.phase).font(.caption.weight(.medium)) - Text("\(progress.completed)/\(progress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary) + ProgressView(value: Double(task.progress.completed), total: Double(max(1, task.progress.total))).frame(maxWidth: 240).tint(AppTheme.orange) + Text(task.progress.phase).font(.caption.weight(.medium)) + Text("\(task.progress.completed)/\(task.progress.total)").font(.caption.monospacedDigit()).foregroundStyle(.secondary) + if let name = task.currentScrapingName ?? task.currentWritingName { Text(name).font(.caption).foregroundStyle(.tertiary).lineLimit(1) } Spacer() + Button(role: .destructive) { model.cancelMediaLibraryTask(task.id) } label: { Image(systemName: "stop.fill") } + .buttonStyle(.bordered).help("停止此任务") } } - HStack { - if let collection = activeCollection { - Button { activeCollectionID = nil; selectedItemID = nil } label: { Image(systemName: "chevron.left") }.buttonStyle(.bordered).help("返回合集") - Text(collection.name).font(.callout.weight(.semibold)).lineLimit(1) - } - Picker("分类", selection: $filter) { ForEach(MediaLibraryFilter.allCases) { Text($0.rawValue).tag($0) } } - .pickerStyle(.segmented).labelsHidden().frame(width: 440) - Spacer() - Label(filter == .collections && activeCollection == nil ? "\(collectionGroups.count) 个合集" : "\(visibleItems.count) 个条目", systemImage: filter == .collections ? "rectangle.stack" : "rectangle.grid.2x2").font(.caption.monospacedDigit()).foregroundStyle(.secondary) - Text("·").foregroundStyle(.tertiary) - Label("\(items.count) 个资源", systemImage: "film.stack").font(.caption.monospacedDigit()).foregroundStyle(.secondary) - } } - .padding(.horizontal, 28).padding(.vertical, 20) - .background(.ultraThinMaterial) - .overlay(alignment: .bottom) { Divider().opacity(0.45) } + .padding(16) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(.white.opacity(0.25), lineWidth: 1) } + .padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 8) } @ViewBuilder private var libraryContent: some View { if isLoadingCache && items.isEmpty { MediaPosterWallSkeleton(columns: posterColumns, count: 18).padding(28) - } else if model.isScanningMediaLibrary && items.isEmpty { + } else if currentLibraryTask?.isActive == true && items.isEmpty { MediaPosterWallSkeleton(columns: posterColumns, count: 18).padding(28) } else if !errorText.isEmpty && items.isEmpty { ContentUnavailableView("影视库加载失败", systemImage: "exclamationmark.triangle", description: Text(errorText)).frame(maxWidth: .infinity).padding(.vertical, 70) @@ -297,45 +683,312 @@ struct MediaLibraryPage: View { } } .padding(.horizontal, 28).padding(.top, 24).padding(.bottom, 36) + } else if model.mediaLibraryDestination == .all && filter == .all && model.mediaLibrarySearchText.isEmpty { + mediaHomeContent } else if visibleItems.isEmpty { ContentUnavailableView("影视库暂无内容", systemImage: "film.stack", description: Text("点击扫描按钮读取云盘视频并更新刮削信息。")) .frame(maxWidth: .infinity).padding(.vertical, 70) } else { VStack(alignment: .leading, spacing: 18) { + if model.mediaLibraryDestination == .all && filter == .all && model.mediaLibrarySearchText.isEmpty && !model.mediaLibraries.isEmpty { + VStack(alignment: .leading, spacing: 12) { + Text("媒体库").font(.title3.weight(.bold)) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(model.mediaLibraries) { library in + MediaLibraryShortcutCard(library: library, itemCount: model.mediaLibraryLiveItemsByLibraryID[library.id]?.count ?? 0) { + model.mediaLibraryDestination = .library(library.id) + } + } + } + } + } + } HStack { Text(wallTitle).font(.title2.weight(.bold)) Spacer() - if model.isScanningMediaLibrary { Text("新条目会自动加入").font(.caption).foregroundStyle(.secondary) } + if filter == .unmatched, !visibleItems.isEmpty { + Button { + Task { + _ = await model.batchRecognizeUnmatchedMediaItems(visibleItems, preferredLibraryID: selectedLibraryID) + } + } label: { + Label(model.isBatchRecognizingMedia ? "正在批量识别" : "批量自动识别 \(visibleItems.count) 项", systemImage: "sparkle.magnifyingglass") + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(model.isBatchRecognizingMedia || currentLibraryTask?.isActive == true) + } + if currentLibraryTask?.isActive == true { Text("新条目会自动加入").font(.caption).foregroundStyle(.secondary) } } LazyVGrid(columns: posterColumns, alignment: .leading, spacing: 24) { ForEach(visibleItems) { item in MediaPosterCard(item: item, resourceCount: resourceCount(for: item), isSelected: selectedItemID == item.id) { withAnimation(.easeInOut(duration: 0.18)) { selectedItemID = item.id } + detailItem = item } onOpen: { play(item) } .contextMenu { - Button { play(item) } label: { Label("播放", systemImage: "play.fill") } + Menu("播放") { + if AppModel.installedPlayers.isEmpty { + Button { play(item) } label: { Label("系统默认播放器", systemImage: "play.fill") } + } else { + ForEach(AppModel.installedPlayers, id: \.name) { player in + Button { Task { await model.openFile(item.file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") } + } + } + } Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") } Button { Task { await model.share(item.file) } } label: { Label("分享", systemImage: "square.and.arrow.up") } + if !item.isMatched { Button { scrapeUnmatchedItem(item) } label: { Label("刮削", systemImage: "wand.and.stars") } } + Button { manualRecognitionItem = item } label: { Label("手动识别", systemImage: "magnifyingglass") } } + .id("wall-\(item.id)") } - if model.isScanningMediaLibrary { - ForEach(0.. languageScore($0) }) ?? true { preferred[key] = item } + } + mediaSearchResults = preferred.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + isLoadingMediaSearch = false + } + + private func mediaSearchResourceCount(for item: MediaLibraryItem) -> Int { + guard let tmdbID = item.tmdbID else { return 1 } + return mediaSearchResults.filter { $0.mediaKind == item.mediaKind && $0.tmdbID == tmdbID }.count + } + + private func playSearchResult(_ item: MediaLibraryItem) { + guard !item.file.isDirectory else { + model.lastActionMessage = "原盘已作为完整目录入库;请使用支持蓝光/DVD 目录的本地播放器打开该目录。" + return + } + PlayerWindowCoordinator.shared.open(model: model, file: item.file) + } + + private var mediaHomeContent: some View { + VStack(alignment: .leading, spacing: 34) { + if model.tmdbAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Label("TMDB API Key 未配置,新增内容无法自动识别。", systemImage: "exclamationmark.triangle.fill") + .font(.callout).foregroundStyle(.yellow) + .padding(.horizontal, 13).padding(.vertical, 10) + .background(Color.yellow.opacity(0.10), in: RoundedRectangle(cornerRadius: 7)) + } + if !model.mediaLibraries.isEmpty { + VStack(alignment: .leading, spacing: 13) { + mediaShelfHeading("媒体库", actionTitle: "管理") { model.mediaLibraryDestination = .management } + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 16) { + ForEach(model.mediaLibraries) { library in + MediaLibraryVisualCard(library: library, items: itemsForLibrary(library.id), itemCount: libraryItemCount(library.id)) { + model.mediaLibraryDestination = .library(library.id) + } + } + } + } + } + } + let recentlyAdded = visibleItems.sorted { $0.file.modifiedAt.localizedStandardCompare($1.file.modifiedAt) == .orderedDescending } + if !recentlyAdded.isEmpty { mediaShelf(title: "最近入库", items: recentlyAdded, style: .landscape) } + let movies = visibleItems.filter { $0.mediaKind == .movie } + if !movies.isEmpty { mediaShelf(title: "电影", items: movies, filter: .movies) } + let series = visibleItems.filter { $0.mediaKind == .tv } + if !series.isEmpty { mediaShelf(title: "电视剧", items: series, filter: .series) } + let unmatched = visibleItems.filter { !$0.isMatched } + if !unmatched.isEmpty { mediaShelf(title: "待识别", items: unmatched, filter: .unmatched) } + cachedItemsLoadMoreFooter + } + .padding(.horizontal, 32).padding(.top, 27).padding(.bottom, 42) + } + + private func mediaShelf(title: String, items: [MediaLibraryItem], filter targetFilter: MediaLibraryFilter? = nil, style: MediaShelfStyle = .poster) -> some View { + VStack(alignment: .leading, spacing: 13) { + mediaShelfHeading(title, actionTitle: targetFilter == nil ? nil : "查看全部") { + guard let targetFilter else { return } + filter = targetFilter + } + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: 14) { + ForEach(Array(items.prefix(18))) { item in + Group { + if style == .landscape { + MediaLandscapeCard(item: item, onOpen: { detailItem = item }, onPlay: { play(item) }) + } else { + MediaPosterCard(item: item, resourceCount: resourceCount(for: item), isSelected: selectedItemID == item.id) { + selectedItemID = item.id + detailItem = item + } onOpen: { play(item) } + .frame(width: 136) + } + } + .contextMenu { + Menu("播放") { + if AppModel.installedPlayers.isEmpty { + Button { play(item) } label: { Label("系统默认播放器", systemImage: "play.fill") } + } else { + ForEach(AppModel.installedPlayers, id: \.name) { player in + Button { Task { await model.openFile(item.file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") } + } + } + } + Button { Task { await model.download(item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") } + if !item.isMatched { Button { scrapeUnmatchedItem(item) } label: { Label("刮削", systemImage: "wand.and.stars") } } + Button { manualRecognitionItem = item } label: { Label("手动识别", systemImage: "magnifyingglass") } + } + } + } + } + } + } + + private func mediaShelfHeading(_ title: String, actionTitle: String? = nil, action: @escaping () -> Void = {}) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(title).font(.title3.weight(.bold)) + Spacer() + if let actionTitle { + Button(action: action) { + Label(actionTitle, systemImage: "chevron.right") + .font(.caption.weight(.medium)).labelStyle(.titleAndIcon) + } + .buttonStyle(.plain).foregroundStyle(.secondary) + } + } + } + + @ViewBuilder private var cachedItemsLoadMoreFooter: some View { + if isLoadingMoreCachedItems { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("正在加载更多影视…") + } + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } else if canLoadMoreCachedItems { + Color.clear + .frame(height: 1) + .onAppear { Task { await loadMoreCachedItems() } } + Button { Task { await loadMoreCachedItems() } } label: { + Label("加载更多影视", systemImage: "arrow.down.circle") + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + } + + private func libraryItemCount(_ libraryID: String) -> Int { + model.mediaLibraryStatisticsByLibraryID[libraryID]?.total ?? model.mediaLibraryLiveItemsByLibraryID[libraryID]?.count ?? libraryVisualItems[libraryID]?.count ?? (selectedLibraryID == libraryID ? items.count : 0) + } + + private func itemsForLibrary(_ libraryID: String) -> [MediaLibraryItem] { + model.mediaLibraryLiveItemsByLibraryID[libraryID] ?? libraryVisualItems[libraryID] ?? (selectedLibraryID == libraryID ? items : []) + } + @MainActor private func initializeLibrary() async { isLoadingCache = true defer { isLoadingCache = false } await model.loadMediaLibraries() + let removedLegacyEntries = await model.removeLegacyIgnoredMediaItemsIfNeeded() + if removedLegacyEntries > 0 { + model.lastActionMessage = "已清理 \(removedLegacyEntries) 个原盘内部序号文件(如 00000),它们不会再参与刮削。" + } if let target = model.mediaLibraryTarget, target.isDirectory { if let existing = model.mediaLibraries.first(where: { library in library.sources.contains { $0.rootID == target.id } }) { model.mediaLibraryDestination = .library(existing.id) selectedLibraryID = existing.id - items = await model.cachedMediaLibraryItems(libraryID: existing.id) + items = await model.cachedMediaLibraryPage(libraryID: existing.id, limit: cachedItemsPageSize) + cachedItemsOffset = items.count + canLoadMoreCachedItems = items.count == cachedItemsPageSize + libraryVisualItems[existing.id] = items } else { let library = await model.createMediaLibrary(name: target.name, rootID: target.id, rootPath: target.cloudPath, kind: .mixed, recursive: true) model.mediaLibraryDestination = .library(library.id) @@ -353,16 +1006,22 @@ struct MediaLibraryPage: View { switch model.mediaLibraryDestination { case .all: selectedLibraryID = nil - var loaded: [MediaLibraryItem] = [] - for library in model.mediaLibraries { loaded += await model.cachedMediaLibraryItems(libraryID: library.id) } - items = Array(Dictionary(grouping: loaded, by: \.id).compactMap { $0.value.first }) - .sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + items = await model.cachedMediaLibraryPage(libraryID: nil, limit: cachedItemsPageSize) case .library(let id): selectedLibraryID = id - items = await model.cachedMediaLibraryItems(libraryID: id) + items = await model.cachedMediaLibraryPage(libraryID: id, limit: cachedItemsPageSize) + libraryVisualItems[id] = items + case .management: + selectedLibraryID = nil + case .categories: + selectedLibraryID = nil case .tasks: selectedLibraryID = nil + case .tmdb: + selectedLibraryID = nil } + cachedItemsOffset = items.count + canLoadMoreCachedItems = items.count == cachedItemsPageSize if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id } } @@ -370,29 +1029,47 @@ struct MediaLibraryPage: View { guard let library = selectedLibrary else { items = []; return } isLoadingCache = true; errorText = "" defer { isLoadingCache = false } - items = await model.cachedMediaLibraryItems(libraryID: library.id) + items = await model.cachedMediaLibraryPage(libraryID: library.id, limit: cachedItemsPageSize) + cachedItemsOffset = items.count + canLoadMoreCachedItems = items.count == cachedItemsPageSize + libraryVisualItems[library.id] = items if !items.contains(where: { $0.id == selectedItemID }) { selectedItemID = items.first?.id } } - @MainActor private func startScan(_ library: MediaLibraryDefinition) { - guard mediaScanTask == nil else { return } - mediaScanTask = Task { - await scan(library) - mediaScanTask = nil + @MainActor private func loadMoreCachedItems() async { + guard canLoadMoreCachedItems, !isLoadingMoreCachedItems else { return } + let destination = model.mediaLibraryDestination + let libraryID: String? + switch destination { + case .all: libraryID = nil + case .library(let id): libraryID = id + default: return + } + isLoadingMoreCachedItems = true + defer { isLoadingMoreCachedItems = false } + let page = await model.cachedMediaLibraryPage(libraryID: libraryID, limit: cachedItemsPageSize, offset: cachedItemsOffset) + guard destination == model.mediaLibraryDestination else { return } + cachedItemsOffset += page.count + canLoadMoreCachedItems = page.count == cachedItemsPageSize + guard !page.isEmpty else { return } + var merged = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) + page.forEach { merged[$0.id] = $0 } + items = merged.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + if let libraryID { libraryVisualItems[libraryID] = items } + } + + private func scrapeUnmatchedItem(_ item: MediaLibraryItem) { + guard !item.isMatched else { return } + Task { + _ = await model.batchRecognizeUnmatchedMediaItems([item], preferredLibraryID: selectedLibraryID) + await loadDestination() + if let refreshed = items.first(where: { $0.id == item.id }) { detailItem = refreshed } } } - @MainActor private func cancelScan() { - mediaScanTask?.cancel() - } - - @MainActor private func scan(_ library: MediaLibraryDefinition) async { + @MainActor private func startScan(_ library: MediaLibraryDefinition) { errorText = "" - do { - items = try await model.scanMediaLibrary(library) - selectedItemID = items.first?.id - } catch is CancellationError { } - catch { errorText = error.localizedDescription } + _ = model.startMediaLibraryScan(library) } private func resourceCount(for item: MediaLibraryItem) -> Int { @@ -403,28 +1080,639 @@ struct MediaLibraryPage: View { private func languageScore(_ item: MediaLibraryItem) -> Int { (item.hasChineseAudio ? 2 : 0) + (item.hasChineseSubtitle ? 1 : 0) } private func play(_ item: MediaLibraryItem) { + guard !item.file.isDirectory else { + model.lastActionMessage = "原盘已作为完整目录入库;请使用支持蓝光/DVD 目录的本地播放器打开该目录。" + return + } let preferred = items.filter { candidate in guard let tmdbID = item.tmdbID else { return candidate.id == item.id } return candidate.mediaKind == item.mediaKind && candidate.tmdbID == tmdbID }.max { languageScore($0) < languageScore($1) } ?? item - Task { await model.playWithExternalPlayer(preferred.file) } + PlayerWindowCoordinator.shared.open(model: model, file: preferred.file) + } +} + +private struct MediaEpisodeEntry: Identifiable { + let item: MediaLibraryItem + let season: Int + let episode: Int? + var id: String { item.id } +} + +private struct MediaItemDetailPage: View { + @ObservedObject var model: AppModel + let item: MediaLibraryItem + let items: [MediaLibraryItem] + let preferredLibraryID: String? + let onBack: () -> Void + let onPlay: (MediaLibraryItem) -> Void + let onScrape: (MediaLibraryItem) -> Void + let onRecognized: (MediaLibraryItem) -> Void + @State private var showManualRecognition = false + @State private var artworkAssets: [MediaArtworkAsset] = [] + @State private var isLoadingArtwork = false + @State private var castMembers: [MediaCastMember] = [] + @State private var isLoadingCast = false + @State private var selectedResourceID: String? + + private var relatedResources: [MediaLibraryItem] { + guard let tmdbID = item.tmdbID, let kind = item.mediaKind else { return [item] } + let values = items.filter { $0.tmdbID == tmdbID && $0.mediaKind == kind } + return values.isEmpty ? [item] : values + } + + private var selectedResource: MediaLibraryItem { + relatedResources.first(where: { $0.id == selectedResourceID }) ?? item + } + + private var episodes: [MediaEpisodeEntry] { + guard item.mediaKind == .tv else { return [] } + return relatedResources.map { resource in + let parsed = model.parsedTMDBInfo(for: resource.file) + return MediaEpisodeEntry(item: resource, season: parsed.season ?? 1, episode: parsed.episode) + } + .sorted { + if $0.season != $1.season { return $0.season < $1.season } + if $0.episode != $1.episode { return ($0.episode ?? .max) < ($1.episode ?? .max) } + return $0.item.file.name.localizedStandardCompare($1.item.file.name) == .orderedAscending + } + } + + private var seasons: [Int] { Array(Set(episodes.map(\.season))).sorted() } + private var posterAssets: [MediaArtworkAsset] { artworkAssets.filter { $0.label.contains("海报") } } + private var backdropAssets: [MediaArtworkAsset] { artworkAssets.filter { $0.label.contains("横幅") || $0.label.contains("同人画") } } + private var logoAssets: [MediaArtworkAsset] { artworkAssets.filter { $0.label.localizedCaseInsensitiveContains("logo") } } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Button(action: onBack) { Image(systemName: "chevron.left") } + .buttonStyle(.bordered).help("返回海报墙") + Text(item.isMatched ? "影视详情" : "文件详情").font(.headline) + Spacer() + Button { Task { await model.share(item.file) } } label: { Image(systemName: "square.and.arrow.up") } + .buttonStyle(.bordered).help("分享") + } + .padding(.horizontal, 28).padding(.vertical, 14) + .background(.ultraThinMaterial) + + ScrollView { + VStack(alignment: .leading, spacing: 28) { + detailHero + if item.isMatched && !item.overview.isEmpty { detailOverview } + if item.isMatched { artworkGallery } + if item.isMatched { castGallery } + if item.mediaKind == .tv && !episodes.isEmpty { episodeList } + else { resourceList } + fileInfo + } + .padding(.horizontal, 30).padding(.vertical, 26) + } + } + .sheet(isPresented: $showManualRecognition) { + ManualMediaRecognitionSheet(model: model, item: item, preferredLibraryID: preferredLibraryID) { updated in + showManualRecognition = false + onRecognized(updated) + } + } + .task(id: item.tmdbID) { + guard item.isMatched else { return } + isLoadingArtwork = true + artworkAssets = await model.mediaArtworkAssets(for: item) + isLoadingArtwork = false + isLoadingCast = true + castMembers = await model.mediaCastMembers(for: item) + isLoadingCast = false + } + } + + private var detailHero: some View { + ZStack(alignment: .bottomLeading) { + MediaArtwork(data: item.backdropData, icon: "film.stack", contentMode: .fill) + .frame(maxWidth: .infinity).frame(height: 310).clipped() + .overlay { + LinearGradient(colors: [.black.opacity(0.12), .black.opacity(0.82)], startPoint: .top, endPoint: .bottom) + } + HStack(alignment: .bottom, spacing: 20) { + MediaArtwork(data: item.posterData, icon: item.file.isDirectory ? "opticaldisc.fill" : "film.fill", contentMode: .fill) + .frame(width: 146, height: 219).clipShape(RoundedRectangle(cornerRadius: 6)) + .shadow(color: .black.opacity(0.35), radius: 12, y: 5) + VStack(alignment: .leading, spacing: 11) { + Text(item.title).font(.system(size: 30, weight: .bold)).foregroundStyle(.white).lineLimit(2) + if item.isMatched, !item.originalTitle.isEmpty, item.originalTitle != item.title { + Text(item.originalTitle).font(.callout).foregroundStyle(.white.opacity(0.75)) + } + HStack(spacing: 7) { + if !item.year.isEmpty { detailTag(item.year) } + if let kind = item.mediaKind { detailTag(kind == .movie ? "电影" : "剧集") } + if item.mediaKind == .tv { detailTag("\(episodes.count) 集") } + if item.hasChineseAudio { detailTag("中文音轨") } + if item.hasChineseSubtitle { detailTag("中文字幕") } + } + HStack(spacing: 9) { + if !selectedResource.file.isDirectory { + Menu { + if AppModel.installedPlayers.isEmpty { + Button { onPlay(selectedResource) } label: { Label("系统默认播放器", systemImage: "play.fill") } + } else { + ForEach(AppModel.installedPlayers, id: \.name) { player in + Button { Task { await model.openFile(selectedResource.file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") } + } + } + } label: { Label("播放", systemImage: "play.fill") } + .buttonStyle(.borderedProminent) + } + Button { Task { await model.download(selectedResource.file) } } label: { Label("下载", systemImage: "arrow.down.circle") } + .buttonStyle(.bordered).tint(.white) + if !item.isMatched { + Button { onScrape(selectedResource) } label: { Label("刮削", systemImage: "wand.and.stars") } + .buttonStyle(.borderedProminent) + .disabled(model.isBatchRecognizingMedia) + } + Button("手动识别") { showManualRecognition = true }.buttonStyle(.bordered).tint(.white) + } + } + Spacer(minLength: 0) + } + .padding(24) + } + .clipShape(RoundedRectangle(cornerRadius: 7)) + .overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(0.13), lineWidth: 1) } + .overlay(alignment: .bottomTrailing) { + if let collection = item.collectionName, !collection.isEmpty { + Label(collection, systemImage: "rectangle.stack").font(.caption.weight(.medium)).foregroundStyle(.white.opacity(0.82)).padding(16) + } + } + } + + private var detailOverview: some View { + Text(item.overview) + .font(.callout) + .foregroundStyle(.secondary) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 7)) + .overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(0.10), lineWidth: 1) } + } + + private var episodeList: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("剧集").font(.title3.weight(.bold)) + Text("\(episodes.count) 集").font(.caption.monospacedDigit()).foregroundStyle(.secondary) + Spacer() + } + ForEach(seasons, id: \.self) { season in + VStack(alignment: .leading, spacing: 6) { + Text("第 \(season) 季").font(.headline) + ForEach(episodes.filter { $0.season == season }) { entry in + Button { onPlay(entry.item) } label: { + HStack(spacing: 12) { + Text(entry.episode.map { String(format: "E%02d", $0) } ?? "文件") + .font(.callout.monospacedDigit().weight(.semibold)).frame(width: 54, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + Text(entry.item.file.name).font(.callout).lineLimit(1) + Text("\(entry.item.file.formattedSize) · \(entry.item.file.modifiedAt.isEmpty ? "未知时间" : entry.item.file.modifiedAt)") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + versionTag(for: entry.item) + if entry.item.hasChineseAudio { Image(systemName: "waveform").foregroundStyle(.secondary).help("中文音轨") } + if entry.item.hasChineseSubtitle { Image(systemName: "captions.bubble").foregroundStyle(.secondary).help("中文字幕") } + Image(systemName: "play.circle.fill").foregroundStyle(.blue) + } + .padding(.horizontal, 14).padding(.vertical, 11).contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 6)) + .contextMenu { + Button { onPlay(entry.item) } label: { Label("播放", systemImage: "play.fill") } + Divider() + if AppModel.installedPlayers.isEmpty { + Button { Task { await model.playWithExternalPlayer(entry.item.file) } } label: { Label("系统默认播放器", systemImage: "play.fill") } + } else { + ForEach(AppModel.installedPlayers, id: \.name) { player in + Button { Task { await model.openFile(entry.item.file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") } + } + } + Divider() + Button { Task { await model.download(entry.item.file) } } label: { Label("下载", systemImage: "arrow.down.circle") } + } + } + } + .padding(.top, 4) + } + } + } + + @ViewBuilder private var artworkGallery: some View { + if isLoadingArtwork && artworkAssets.isEmpty { + HStack(spacing: 10) { ProgressView().controlSize(.small); Text("正在加载图片素材").font(.callout).foregroundStyle(.secondary) } + } else if !artworkAssets.isEmpty { + VStack(alignment: .leading, spacing: 24) { + if !posterAssets.isEmpty { + artworkHeading("海报", count: posterAssets.count) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: 12) { + ForEach(posterAssets) { asset in + artworkPoster(asset) + } + } + } + } + if !backdropAssets.isEmpty { + artworkHeading("横幅与同人画", count: backdropAssets.count) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 230, maximum: 340), spacing: 12)], spacing: 12) { + ForEach(backdropAssets) { asset in artworkBackdrop(asset) } + } + } + if !logoAssets.isEmpty { + artworkHeading("Logo", count: logoAssets.count) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 12) { + ForEach(logoAssets) { asset in artworkLogo(asset) } + } + } + } + } + } + } + + private func artworkHeading(_ title: String, count: Int) -> some View { + HStack { + Text(title).font(.title3.weight(.bold)) + Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(.secondary) + Spacer() + } + } + + private func artworkPoster(_ asset: MediaArtworkAsset) -> some View { + ZStack(alignment: .bottomLeading) { + MediaArtwork(data: asset.data, icon: "photo", contentMode: .fill) + .frame(width: 126, height: 189).clipped() + Text(asset.label).font(.caption2.weight(.medium)).foregroundStyle(.white) + .padding(.horizontal, 6).padding(.vertical, 3).background(.black.opacity(0.66), in: RoundedRectangle(cornerRadius: 4)).padding(6) + } + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.10), lineWidth: 1) } + } + + private func artworkBackdrop(_ asset: MediaArtworkAsset) -> some View { + ZStack(alignment: .bottomLeading) { + MediaArtwork(data: asset.data, icon: "photo", contentMode: .fill) + .aspectRatio(16 / 9, contentMode: .fit).frame(maxWidth: .infinity).clipped() + Text(asset.label).font(.caption2.weight(.medium)).foregroundStyle(.white) + .padding(.horizontal, 6).padding(.vertical, 3).background(.black.opacity(0.66), in: RoundedRectangle(cornerRadius: 4)).padding(6) + } + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.10), lineWidth: 1) } + } + + private func artworkLogo(_ asset: MediaArtworkAsset) -> some View { + MediaArtwork(data: asset.data, icon: "textformat", contentMode: .fit) + .frame(width: 190, height: 92) + .padding(10) + .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 6)) + .overlay { RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.10), lineWidth: 1) } + } + + @ViewBuilder private var castGallery: some View { + if isLoadingCast && castMembers.isEmpty { + HStack(spacing: 10) { ProgressView().controlSize(.small); Text("正在同步演员素材").font(.callout).foregroundStyle(.secondary) } + } else if !castMembers.isEmpty { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("演员").font(.title3.weight(.bold)) + Text("\(castMembers.count)").font(.caption.monospacedDigit()).foregroundStyle(.secondary) + Spacer() + } + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: 12) { + ForEach(castMembers) { member in + VStack(alignment: .leading, spacing: 6) { + MediaArtwork(data: member.imageData, icon: "person.fill", contentMode: .fill) + .frame(width: 96, height: 122).clipShape(RoundedRectangle(cornerRadius: 6)) + Text(member.name).font(.caption.weight(.semibold)).lineLimit(1).frame(width: 96, alignment: .leading) + if !member.role.isEmpty { Text(member.role).font(.caption2).foregroundStyle(.secondary).lineLimit(1).frame(width: 96, alignment: .leading) } + } + } + } + } + } + } + } + + private var resourceList: some View { + VStack(alignment: .leading, spacing: 12) { + Text(relatedResources.count > 1 ? "资源版本" : "媒体资源").font(.title3.weight(.bold)) + ForEach(relatedResources) { resource in + HStack(spacing: 12) { + Image(systemName: resource.file.isDirectory ? "opticaldisc.fill" : "play.rectangle.fill").foregroundStyle(.blue).frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(resource.file.name).font(.callout).lineLimit(1) + Text("\(resource.file.formattedSize) · \(resource.file.modifiedAt.isEmpty ? "未知时间" : resource.file.modifiedAt)").font(.caption).foregroundStyle(.secondary) + } + Spacer() + versionTag(for: resource) + if !resource.file.isDirectory { + Menu { + if AppModel.installedPlayers.isEmpty { + Button { onPlay(resource) } label: { Label("系统默认播放器", systemImage: "play.fill") } + } else { + ForEach(AppModel.installedPlayers, id: \.name) { player in + Button { Task { await model.openFile(resource.file, withPlayerNamed: player.name) } } label: { Label(player.name, systemImage: "play.fill") } + } + } + } label: { Image(systemName: "play.fill") }.buttonStyle(.bordered).help("播放") + } + } + .padding(.horizontal, 14).padding(.vertical, 11) + .contentShape(Rectangle()) + .onTapGesture { selectedResourceID = resource.id } + .background(resource.id == selectedResource.id ? Color.blue.opacity(0.12) : Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 6)) + .overlay { RoundedRectangle(cornerRadius: 6).stroke(resource.id == selectedResource.id ? Color.blue.opacity(0.46) : .clear, lineWidth: 1) } + .accessibilityAddTraits(resource.id == selectedResource.id ? .isSelected : []) + } + } + } + + private func versionTag(for resource: MediaLibraryItem) -> some View { + let parsed = model.parsedTMDBInfo(for: resource.file) + let resolution: String + if resource.file.isDirectory || parsed.isDiscStructure { resolution = "原盘" } + else { + switch parsed.resolution?.lowercased() { + case "2160p", "4k": resolution = "4K" + case "1080p", "1080i": resolution = "1080P" + case "720p": resolution = "720P" + case "480p": resolution = "480P" + default: resolution = "未知" + } + } + let details = [resolution, parsed.source, parsed.videoCodec].compactMap { $0 }.joined(separator: " · ") + return Text(details).font(.caption.monospacedDigit().weight(.medium)).foregroundStyle(.secondary) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(Color.blue.opacity(0.10), in: RoundedRectangle(cornerRadius: 5)) + .help(details) + } + + private var fileInfo: some View { + VStack(alignment: .leading, spacing: 11) { + HStack { + Text("文件信息").font(.title3.weight(.bold)) + if relatedResources.count > 1 { Text("当前版本").font(.caption.weight(.medium)).foregroundStyle(.blue) } + Spacer() + } + detailRow("名称", selectedResource.file.name) + detailRow("路径", selectedResource.file.cloudPath.isEmpty ? "--" : selectedResource.file.cloudPath, selectable: true) + detailRow("类型", selectedResource.file.isDirectory ? "原盘目录" : selectedResource.file.typeName) + detailRow("大小", selectedResource.file.formattedSize) + detailRow("修改时间", selectedResource.file.modifiedAt.isEmpty ? "--" : selectedResource.file.modifiedAt) + } + } + + private func detailTag(_ value: String) -> some View { + Text(value).font(.caption.weight(.medium)).padding(.horizontal, 8).padding(.vertical, 4) + .foregroundStyle(.white).background(.black.opacity(0.40), in: RoundedRectangle(cornerRadius: 5)) + } + + private func detailRow(_ label: String, _ value: String, selectable: Bool = false) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 18) { + Text(label).foregroundStyle(.secondary).frame(width: 68, alignment: .leading) + if selectable { Text(value).fixedSize(horizontal: false, vertical: true).textSelection(.enabled) } + else { Text(value).fixedSize(horizontal: false, vertical: true) } + Spacer(minLength: 0) + } + .font(.callout) + } +} + +private struct ManualMediaRecognitionSheet: View { + @ObservedObject var model: AppModel + let item: MediaLibraryItem + let preferredLibraryID: String? + let onRecognized: (MediaLibraryItem) -> Void + @Environment(\.dismiss) private var dismiss + @State private var query = "" + @State private var mediaKind: TMDBMediaKind = .automatic + @State private var year = "" + @State private var episodeLabel = "" + @State private var candidates: [TMDBCandidate] = [] + @State private var isSearching = false + @State private var isApplying = false + @State private var errorText = "" + + private var parsedInfo: ParsedMediaName { model.parsedTMDBInfo(for: item) } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 13) { + Image(systemName: "sparkle.magnifyingglass") + .font(.title2.weight(.semibold)).foregroundStyle(.blue) + .frame(width: 44, height: 44) + .background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + VStack(alignment: .leading, spacing: 3) { + Text("手动识别").font(.title2.weight(.bold)) + Text("修改检索条件后选择正确的 TMDB 条目。路径和原文件不会被改写。") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button { dismiss() } label: { Image(systemName: "xmark") }.buttonStyle(.bordered).help("关闭") + } + sourceSummary + HStack(alignment: .bottom, spacing: 10) { + VStack(alignment: .leading, spacing: 5) { + Text("用于刮削的标题").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + TextField("影视名称", text: $query).textFieldStyle(.roundedBorder).onSubmit(search) + } + Button(action: search) { Image(systemName: "magnifyingglass") } + .buttonStyle(.bordered).disabled(query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSearching) + .help("搜索 TMDB") + } + HStack(spacing: 12) { + Picker("类型", selection: $mediaKind) { + ForEach(TMDBMediaKind.allCases) { kind in Text(kind.title).tag(kind) } + } + .pickerStyle(.segmented).labelsHidden().frame(width: 240) + TextField("年份", text: $year).textFieldStyle(.roundedBorder).frame(width: 88) + if !episodeLabel.isEmpty { + Label(episodeLabel, systemImage: "list.number").font(.callout).foregroundStyle(.secondary) + } + Spacer() + } + if isSearching { + ProgressView("正在搜索 TMDB…").frame(maxWidth: .infinity, minHeight: 210) + } else if !errorText.isEmpty { + ContentUnavailableView("搜索失败", systemImage: "exclamationmark.triangle", description: Text(errorText)).frame(maxWidth: .infinity, minHeight: 210) + } else if candidates.isEmpty { + ContentUnavailableView("未找到候选项", systemImage: "magnifyingglass", description: Text("可修改标题后再次搜索。")) + .frame(maxWidth: .infinity, minHeight: 210) + } else { + List(candidates) { candidate in + Button { + apply(candidate) + } label: { + HStack(alignment: .top, spacing: 12) { + Image(systemName: candidate.mediaType == .movie ? "film" : "play.tv") + .frame(width: 28, height: 34).foregroundStyle(.blue) + VStack(alignment: .leading, spacing: 3) { + Text(candidate.title).font(.headline) + Text("\(candidate.mediaType == .movie ? "电影" : "剧集") · \(String(candidate.releaseDate.prefix(4)).isEmpty ? "年份未知" : String(candidate.releaseDate.prefix(4))) · TMDB \(candidate.id)") + .font(.caption).foregroundStyle(.secondary) + if !candidate.overview.isEmpty { Text(candidate.overview).font(.caption).foregroundStyle(.secondary).lineLimit(2) } + } + Spacer() + if isApplying { ProgressView().controlSize(.small) } + } + } + .buttonStyle(.plain).disabled(isApplying) + } + .listStyle(.inset) + } + } + .padding(24).frame(width: 720, height: 640) + .task { + let parsed = parsedInfo + query = item.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? parsed.title : item.title + year = parsed.year.map(String.init) ?? "" + mediaKind = parsed.isEpisode ? .tv : .automatic + if let season = parsed.season, let episode = parsed.episode { + episodeLabel = String(format: "S%02dE%02d", season, episode) + } + search() + } + } + + private var sourceSummary: some View { + VStack(alignment: .leading, spacing: 7) { + HStack { + Text("来源与解析").font(.callout.weight(.semibold)) + Spacer() + if let resolution = parsedInfo.resolution { Text(resolution.uppercased()).font(.caption.monospacedDigit().weight(.medium)).foregroundStyle(.blue) } + if parsedInfo.isEpisode, let season = parsedInfo.season, let episode = parsedInfo.episode { Text(String(format: "S%02dE%02d", season, episode)).font(.caption.monospacedDigit().weight(.medium)).foregroundStyle(.blue) } + } + recognitionSummaryRow("文件名", item.file.name) + recognitionSummaryRow("路径", item.file.cloudPath.isEmpty ? "--" : item.file.cloudPath) + recognitionSummaryRow("已解析", "标题:\(parsedInfo.title)\(parsedInfo.year.map { " · 年份:\($0)" } ?? "")\(parsedInfo.source.map { " · 来源:\($0)" } ?? "")") + } + .padding(12) + .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8)) + .overlay { RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.10), lineWidth: 1) } + } + + private func recognitionSummaryRow(_ label: String, _ value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(label).font(.caption).foregroundStyle(.tertiary).frame(width: 42, alignment: .leading) + Text(value).font(.caption).lineLimit(label == "路径" ? 2 : 1).textSelection(.enabled) + Spacer(minLength: 0) + } + } + + private func search() { + let value = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return } + Task { + isSearching = true; errorText = ""; candidates = [] + defer { isSearching = false } + do { candidates = try await model.manualMediaCandidates(query: value, mediaKind: mediaKind, year: Int(year)) } + catch { errorText = error.localizedDescription } + } + } + + private func apply(_ candidate: TMDBCandidate) { + Task { + isApplying = true; errorText = "" + defer { isApplying = false } + do { onRecognized(try await model.applyManualMediaCandidate(candidate, to: item, preferredLibraryID: preferredLibraryID)) } + catch { errorText = error.localizedDescription } + } + } +} + +private struct MediaTMDBSettingsSheet: View { + @ObservedObject var model: AppModel + @Environment(\.dismiss) private var dismiss + @State private var key = "" + @State private var proxyHost = "" + @State private var proxyPort = "" + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("TMDB 识别配置").font(.title2.weight(.bold)) + Text("用于影视库的自动识别、详情和海报下载。仅保存在当前 Mac。") + .font(.callout).foregroundStyle(.secondary) + } + Spacer() + Button { dismiss() } label: { Image(systemName: "xmark") }.buttonStyle(.bordered).help("关闭") + } + SecureField("TMDB API Key", text: $key).textFieldStyle(.roundedBorder) + HStack { + TextField("代理地址(可选)", text: $proxyHost).textFieldStyle(.roundedBorder) + TextField("端口", text: $proxyPort).textFieldStyle(.roundedBorder).frame(width: 100) + } + VStack(alignment: .leading, spacing: 7) { + HStack { + Label("刮削并发", systemImage: "arrow.triangle.branch") + Spacer() + Text("\(model.mediaScrapeConcurrency)") + .font(.callout.monospacedDigit().weight(.semibold)) + .foregroundStyle(AppTheme.orange) + } + Stepper(value: $model.mediaScrapeConcurrency, in: 3...20) { + Text("同时执行 3–20 个 TMDB 识别任务") + .font(.caption).foregroundStyle(.secondary) + } + } + .padding(12) + .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + Toggle(isOn: $model.isMediaWritebackEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text("识别后回写云盘") + Text("关闭时仅保存到本机影视库;开启后会整理目录并上传刮削信息。") + .font(.caption).foregroundStyle(.secondary) + } + } + .toggleStyle(.switch) + .padding(12) + .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + HStack { + Spacer() + Button("取消") { dismiss() }.keyboardShortcut(.cancelAction) + Button("保存") { + model.saveTMDBSettings(key: key, proxyHost: proxyHost, proxyPort: proxyPort) + dismiss() + } + .buttonStyle(.borderedProminent).tint(.blue).keyboardShortcut(.defaultAction) + } + } + .padding(24).frame(width: 500) + .onAppear { + key = model.tmdbAPIKey + proxyHost = model.tmdbProxyHost + proxyPort = model.tmdbProxyPort + } } } private struct CreateMediaLibrarySheet: View { @ObservedObject var model: AppModel let initialLibrary: MediaLibraryDefinition? - let onSave: (String, [MediaLibrarySource], MediaLibraryKind, Bool) -> Void + let onSave: (String, [MediaLibrarySource], MediaLibraryKind, Bool, Int) -> Void @Environment(\.dismiss) private var dismiss @State private var name: String @State private var sources: [MediaLibrarySource] @State private var kind: MediaLibraryKind @State private var recursive: Bool + @State private var minimumSizeMB: Int @State private var showFolderPicker = false private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) } - init(model: AppModel, initialLibrary: MediaLibraryDefinition? = nil, onSave: @escaping (String, [MediaLibrarySource], MediaLibraryKind, Bool) -> Void) { + init(model: AppModel, initialLibrary: MediaLibraryDefinition? = nil, onSave: @escaping (String, [MediaLibrarySource], MediaLibraryKind, Bool, Int) -> Void) { self.model = model self.initialLibrary = initialLibrary self.onSave = onSave @@ -432,6 +1720,7 @@ private struct CreateMediaLibrarySheet: View { _sources = State(initialValue: initialLibrary?.sources ?? []) _kind = State(initialValue: initialLibrary?.kind ?? .movies) _recursive = State(initialValue: initialLibrary?.recursive ?? true) + _minimumSizeMB = State(initialValue: initialLibrary?.minimumSizeMB ?? 50) } var body: some View { @@ -485,12 +1774,19 @@ private struct CreateMediaLibrarySheet: View { Toggle(isOn: $recursive) { VStack(alignment: .leading, spacing: 2) { Text("包含子文件夹").font(.callout.weight(.semibold)); Text("递归扫描目录中的电影、剧集和刮削文件").font(.caption).foregroundStyle(.secondary) } } + Stepper(value: $minimumSizeMB, in: 0...50_000, step: 10) { + VStack(alignment: .leading, spacing: 2) { + Text("忽略小文件").font(.callout.weight(.semibold)) + Text(minimumSizeMB == 0 ? "不按文件大小过滤" : "小于 \(minimumSizeMB) MB 的视频不会进入自动刮削") + .font(.caption).foregroundStyle(.secondary) + } + } Divider() HStack { if sources.isEmpty { Label("请至少添加一个媒体目录", systemImage: "exclamationmark.circle").font(.caption).foregroundStyle(.secondary) } Spacer() Button("取消") { dismiss() }.keyboardShortcut(.cancelAction) - Button { onSave(trimmedName, sources, kind, recursive); dismiss() } label: { Label(initialLibrary == nil ? "创建并扫描" : "保存并扫描", systemImage: initialLibrary == nil ? "plus" : "checkmark") } + Button { onSave(trimmedName, sources, kind, recursive, minimumSizeMB); dismiss() } label: { Label(initialLibrary == nil ? "创建并扫描" : "保存并扫描", systemImage: initialLibrary == nil ? "plus" : "checkmark") } .buttonStyle(.borderedProminent).tint(.orange).keyboardShortcut(.defaultAction).disabled(trimmedName.isEmpty || sources.isEmpty) } } @@ -499,7 +1795,7 @@ private struct CreateMediaLibrarySheet: View { } .frame(width: 590) .sheet(isPresented: $showFolderPicker) { - ScanFolderPickerSheet(model: model, title: "选择媒体目录", selectionLabel: "添加此目录") { id, path in + ScanFolderPickerSheet(model: model, title: "选择媒体目录", selectionLabel: "添加此目录", allowsFilteringAndSorting: true) { id, path in if id == nil { sources = [MediaLibrarySource(rootID: nil, path: path)] } else if !sources.contains(where: { $0.rootID == id }) { sources.removeAll { $0.rootID == nil } @@ -539,6 +1835,499 @@ private struct MediaCollectionGroup: Identifiable { var artwork: Data? { items.first(where: { $0.posterData != nil })?.posterData } } +private struct MediaLibraryManagementRow: View { + let library: MediaLibraryDefinition + let itemCount: Int + let onOpen: () -> Void + let onScan: () -> Void + let onEdit: () -> Void + let onDelete: () -> Void + @State private var isHovered = false + + private var updatedText: String { + guard let date = library.updatedAt else { return "尚未扫描" } + return date.formatted(.dateTime.year().month().day()) + } + + var body: some View { + HStack(spacing: 0) { + Button(action: onOpen) { + HStack(spacing: 13) { + ZStack { + RoundedRectangle(cornerRadius: 7).fill(Color.blue.opacity(0.12)) + Image(systemName: library.kind.icon).font(.title3.weight(.semibold)).foregroundStyle(.blue) + } + .frame(width: 74, height: 74) + VStack(alignment: .leading, spacing: 5) { + Text(library.name).font(.callout.weight(.semibold)).lineLimit(1) + Text("\(itemCount) 个媒体条目 · \(library.recursive ? "递归扫描" : "仅当前目录")") + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } + .frame(width: 255, alignment: .leading) + } + .buttonStyle(.plain) + Text(library.sources.map(\.path).joined(separator: " · ")) + .font(.callout).foregroundStyle(.secondary).lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + Text(library.kind.title).font(.callout).frame(width: 100, alignment: .leading) + Text(updatedText).font(.callout).foregroundStyle(.secondary).frame(width: 130, alignment: .leading) + HStack(spacing: 12) { + Button(role: .destructive, action: onDelete) { Image(systemName: "trash") }.help("删除媒体库") + Button(action: onEdit) { Image(systemName: "pencil") }.help("编辑媒体库") + Button(action: onScan) { Image(systemName: "arrow.triangle.2.circlepath") }.help("扫描媒体库文件") + Menu { Button("打开媒体库", action: onOpen); Button("扫描媒体库", action: onScan); Button("编辑媒体库", action: onEdit) } label: { Image(systemName: "ellipsis.circle") } + .menuStyle(.borderlessButton) + } + .buttonStyle(.borderless).foregroundStyle(.secondary) + .frame(width: 180, alignment: .trailing) + } + .padding(.horizontal, 30).frame(minHeight: 116) + .background(isHovered ? Color.primary.opacity(0.035) : .clear) + .onHover { isHovered = $0 } + } +} + +private struct MediaLibraryTaskCard: View { + let task: MediaLibraryTask + let isExpanded: Bool + let onToggle: () -> Void + let onCancel: () -> Void + let onResume: () -> Void + let onRemove: () -> Void + @Environment(\.colorScheme) private var colorScheme + @State private var didCopyAllLogs = false + @State private var copiedLogID: UUID? + + private var tint: Color { + switch task.state { + case .queued: return .secondary + case .scanning: return .blue + case .writing: return .orange + case .completed: return .green + case .cancelled: return .secondary + case .failed: return .red + } + } + + private var logHeight: CGFloat { + let screenHeight = NSScreen.main?.visibleFrame.height ?? 800 + // Header and progress consume about 170pt; keep the whole expanded card + // within roughly half of the visible display by constraining the log pane. + return max(120, min(220, screenHeight * 0.5 - 175)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + Image(systemName: task.state.icon).foregroundStyle(tint).frame(width: 28) + VStack(alignment: .leading, spacing: 3) { + Text(task.libraryName).font(.headline.weight(.semibold)) + Text(task.progress.phase.isEmpty ? task.state.title : task.progress.phase).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Text(task.state.title).font(.caption.weight(.semibold)).foregroundStyle(tint) + if task.isActive { + Button(role: .destructive, action: onCancel) { Image(systemName: "stop.fill") } + .buttonStyle(.bordered).controlSize(.small).help("停止此任务") + } else if task.state == .cancelled || task.state == .failed { + Button(action: onResume) { + Label(task.state == .cancelled ? "继续" : "重试", systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered).controlSize(.small) + .help(task.state == .cancelled ? "继续未完成的识别和写回" : "重新执行失败的任务") + } + if !task.isActive { + Button(role: .destructive, action: onRemove) { Image(systemName: "trash") } + .buttonStyle(.bordered).controlSize(.small).help("清理此任务记录") + } + Button(action: onToggle) { Image(systemName: isExpanded ? "chevron.up" : "chevron.down") } + .buttonStyle(.bordered).controlSize(.small).help(isExpanded ? "收起详情" : "查看详情与日志") + } + HStack(spacing: 10) { + ProgressView(value: Double(task.progress.completed), total: Double(max(1, task.progress.total))) + .tint(tint) + Text("\(task.progress.completed)/\(task.progress.total)") + .font(.caption.monospacedDigit().weight(.medium)) + .foregroundStyle(.secondary) + .frame(minWidth: 54, alignment: .trailing) + } + HStack(spacing: 8) { + MediaTaskStage(title: "扫描 / TMDB 识别", value: task.currentScrapingName ?? "等待媒体文件", icon: "magnifyingglass") + MediaTaskStage(title: "本机入库", value: task.currentStoringName ?? "等待入库", icon: "tray.and.arrow.down") + MediaTaskStage(title: "资源目录写回", value: task.currentWritingName ?? "等待写回", icon: "square.and.arrow.down") + } + if isExpanded { + Divider() + if let failure = task.failureReason, !failure.isEmpty { + Label(failure, systemImage: "exclamationmark.triangle.fill").font(.caption).foregroundStyle(.yellow) + } + VStack(alignment: .leading, spacing: 7) { + HStack { + Text("任务日志").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + Spacer() + Button { + copyAllLogs() + didCopyAllLogs = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { didCopyAllLogs = false } + } label: { + Image(systemName: didCopyAllLogs ? "checkmark" : "doc.on.doc") + } + .buttonStyle(.bordered).controlSize(.small) + .help(didCopyAllLogs ? "已复制全部日志" : "复制全部日志") + } + ScrollView { + LazyVStack(alignment: .leading, spacing: 7) { + ForEach(task.logs.reversed()) { entry in + HStack(alignment: .top, spacing: 9) { + Text(entry.date, format: .dateTime.hour().minute().second()).font(.caption2.monospacedDigit()).foregroundStyle(.tertiary) + Text(entry.message).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + Spacer(minLength: 4) + Button { + copyLog(entry) + copiedLogID = entry.id + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if copiedLogID == entry.id { copiedLogID = nil } + } + } label: { + Image(systemName: copiedLogID == entry.id ? "checkmark" : "doc.on.doc") + } + .buttonStyle(.plain).foregroundStyle(.secondary) + .help(copiedLogID == entry.id ? "已复制" : "复制此行日志") + } + } + } + } + .frame(height: logHeight) + .padding(.trailing, 4) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(16) + .background(Color.primary.opacity(colorScheme == .dark ? 0.07 : 0.035), in: RoundedRectangle(cornerRadius: 7)) + .overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(colorScheme == .dark ? 0.16 : 0.09), lineWidth: 1) } + } + + private func copyAllLogs() { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + let text = task.logs.map { "\(formatter.string(from: $0.date)) \($0.message)" }.joined(separator: "\n") + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + private func copyLog(_ entry: MediaLibraryTaskLog) { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + NSPasteboard.general.clearContents() + NSPasteboard.general.setString("\(formatter.string(from: entry.date)) \(entry.message)", forType: .string) + } +} + +private struct MediaCategoryManagementPage: View { + @ObservedObject var model: AppModel + @State private var editingRule: MediaCategoryRule? + @State private var isCreating = false + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 4) { + Text("影视分类").font(.title2.weight(.bold)) + Text("仅按 TMDB 原始语言匹配分类;刮削信息仍优先使用中文。").font(.callout).foregroundStyle(.secondary) + } + Spacer() + Button { model.resetMediaCategoryRules() } label: { Label("恢复预设", systemImage: "arrow.counterclockwise") } + .buttonStyle(.bordered) + Button { isCreating = true } label: { Label("新增分类", systemImage: "plus") } + .buttonStyle(.borderedProminent).tint(.blue) + } + .padding(.horizontal, 30).padding(.vertical, 20) + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 26) { + categorySection(title: "电影分类", kind: .movie, icon: "film") + categorySection(title: "剧集分类", kind: .tv, icon: "tv") + } + .padding(30).frame(maxWidth: 1040, alignment: .leading) + } + } + .sheet(isPresented: $isCreating) { + MediaCategoryRuleEditorSheet { rule in + model.saveMediaCategoryRules(model.mediaCategoryRules + [rule]) + } + } + .sheet(item: $editingRule) { rule in + MediaCategoryRuleEditorSheet(rule: rule) { updated in + var rules = model.mediaCategoryRules + if let index = rules.firstIndex(where: { $0.id == updated.id }) { rules[index] = updated } + model.saveMediaCategoryRules(rules) + } + } + } + + private func categorySection(title: String, kind: TMDBMediaKind, icon: String) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label(title, systemImage: icon).font(.headline.weight(.semibold)) + VStack(spacing: 0) { + ForEach(model.mediaCategoryRules.filter { $0.mediaKind == kind }) { rule in + categoryRow(rule) + if rule.id != model.mediaCategoryRules.filter({ $0.mediaKind == kind }).last?.id { Divider().padding(.leading, 16) } + } + } + .background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 7)) + .overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(0.09), lineWidth: 1) } + } + } + + private func categoryRow(_ rule: MediaCategoryRule) -> some View { + HStack(spacing: 14) { + Image(systemName: rule.mediaKind == .movie ? "film" : "tv").foregroundStyle(.blue).frame(width: 24) + VStack(alignment: .leading, spacing: 4) { + Text(rule.name).font(.callout.weight(.semibold)) + Text(ruleDetail(rule)).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if rule.isFallback { Text("默认").font(.caption.weight(.medium)).foregroundStyle(.secondary) } + Button { move(rule, by: -1) } label: { Image(systemName: "arrow.up") } + .buttonStyle(.borderless).help("上移匹配优先级").disabled(firstIndex(of: rule) == 0) + Button { move(rule, by: 1) } label: { Image(systemName: "arrow.down") } + .buttonStyle(.borderless).help("下移匹配优先级").disabled(firstIndex(of: rule) == model.mediaCategoryRules.count - 1) + Button { editingRule = rule } label: { Image(systemName: "pencil") } + .buttonStyle(.borderless).help("编辑分类") + Button(role: .destructive) { remove(rule) } label: { Image(systemName: "trash") } + .buttonStyle(.borderless).help("删除分类").disabled(rule.isFallback) + } + .padding(.horizontal, 16).frame(minHeight: 58) + } + + private func ruleDetail(_ rule: MediaCategoryRule) -> String { + rule.languages.isEmpty ? "未设置原始语言" : "语言 \(rule.languages.joined(separator: ", "))" + } + + private func firstIndex(of rule: MediaCategoryRule) -> Int { model.mediaCategoryRules.firstIndex(of: rule) ?? 0 } + + private func move(_ rule: MediaCategoryRule, by offset: Int) { + var rules = model.mediaCategoryRules + guard let index = rules.firstIndex(of: rule) else { return } + let target = index + offset + guard rules.indices.contains(target), rules[target].mediaKind == rule.mediaKind else { return } + rules.swapAt(index, target) + model.saveMediaCategoryRules(rules) + } + + private func remove(_ rule: MediaCategoryRule) { + model.saveMediaCategoryRules(model.mediaCategoryRules.filter { $0.id != rule.id }) + } +} + +private struct MediaCategoryRuleEditorSheet: View { + let rule: MediaCategoryRule? + let onSave: (MediaCategoryRule) -> Void + @Environment(\.dismiss) private var dismiss + @State private var name: String + @State private var mediaKind: TMDBMediaKind + @State private var languageText: String + @State private var isFallback: Bool + + init(rule: MediaCategoryRule? = nil, onSave: @escaping (MediaCategoryRule) -> Void) { + self.rule = rule + self.onSave = onSave + _name = State(initialValue: rule?.name ?? "") + _mediaKind = State(initialValue: rule?.mediaKind ?? .movie) + _languageText = State(initialValue: rule?.languages.joined(separator: ", ") ?? "") + _isFallback = State(initialValue: rule?.isFallback ?? false) + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Text(rule == nil ? "新增分类" : "编辑分类").font(.title2.weight(.bold)) + TextField("分类名称", text: $name).textFieldStyle(.roundedBorder) + Picker("媒体类型", selection: $mediaKind) { + Text("电影").tag(TMDBMediaKind.movie) + Text("剧集").tag(TMDBMediaKind.tv) + } + .pickerStyle(.segmented) + VStack(alignment: .leading, spacing: 6) { + Text("原始语言").font(.callout.weight(.semibold)) + TextField("例如:zh, en, ja, ko", text: $languageText).textFieldStyle(.roundedBorder) + Text("分类目录仅根据此字段匹配,多个语言请用逗号分隔。") + .font(.caption).foregroundStyle(.secondary) + } + Toggle("默认分类", isOn: $isFallback) + HStack { + Spacer() + Button("取消") { dismiss() } + Button("保存") { save() }.buttonStyle(.borderedProminent).disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(24).frame(width: 460) + } + + private func save() { + let languages = languageText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }.filter { !$0.isEmpty } + onSave(MediaCategoryRule(id: rule?.id ?? UUID().uuidString, name: name.trimmingCharacters(in: .whitespacesAndNewlines), mediaKind: mediaKind, genreIDs: [], languages: languages, isFallback: isFallback)) + dismiss() + } +} + +private struct MediaTaskStage: View { + let title: String + let value: String + let icon: String + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Label(title, systemImage: icon).font(.caption2).foregroundStyle(.tertiary) + Text(value).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 6)) + } +} + +private struct MediaLibraryShortcutCard: View { + let library: MediaLibraryDefinition + let itemCount: Int + let action: () -> Void + @Environment(\.colorScheme) private var colorScheme + + private var accent: Color { + switch library.kind { + case .movies: return .blue + case .series: return .pink + case .mixed: return .orange + } + } + + var body: some View { + Button(action: action) { + HStack(spacing: 13) { + Image(systemName: library.kind.icon) + .font(.title2.weight(.semibold)) + .foregroundStyle(accent) + .frame(width: 44, height: 44) + .background(accent.opacity(colorScheme == .dark ? 0.18 : 0.12), in: RoundedRectangle(cornerRadius: 7)) + VStack(alignment: .leading, spacing: 4) { + Text(library.name).font(.callout.weight(.semibold)).lineLimit(1) + Text("\(library.kind.title) · \(itemCount) 个条目").font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer(minLength: 4) + Image(systemName: "chevron.right").font(.caption.weight(.bold)).foregroundStyle(.tertiary) + } + .padding(14) + .frame(width: 238, height: 76) + .background(Color.primary.opacity(colorScheme == .dark ? 0.08 : 0.045), in: RoundedRectangle(cornerRadius: 7)) + .overlay { RoundedRectangle(cornerRadius: 7).stroke(Color.primary.opacity(colorScheme == .dark ? 0.16 : 0.10), lineWidth: 1) } + } + .buttonStyle(.plain) + } +} + +private struct MediaLibraryVisualCard: View { + let library: MediaLibraryDefinition + let items: [MediaLibraryItem] + let itemCount: Int + let action: () -> Void + @State private var isHovered = false + + private var posterItems: [MediaLibraryItem] { Array(items.prefix(4)) } + + var body: some View { + Button(action: action) { + VStack(spacing: 0) { + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.06)) + if posterItems.isEmpty { + VStack(spacing: 8) { + Image(systemName: library.kind.icon).font(.title2.weight(.medium)) + Text("等待扫描").font(.caption) + } + .foregroundStyle(.secondary) + } else { + HStack(spacing: 2) { + ForEach(Array(posterItems.enumerated()), id: \.element.id) { _, item in + MediaArtwork(data: item.posterData, icon: "film.fill", contentMode: .fill) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } + if posterItems.count < 4 { + ForEach(0..<(4 - posterItems.count), id: \.self) { _ in + Color.primary.opacity(0.05) + } + } + } + } + LinearGradient(colors: [.clear, .black.opacity(0.58)], startPoint: .center, endPoint: .bottom) + } + .frame(width: 222, height: 126) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + HStack(spacing: 8) { + Image(systemName: library.kind.icon).foregroundStyle(.blue).font(.caption.weight(.semibold)) + Text(library.name).font(.callout.weight(.semibold)).lineLimit(1) + Spacer(minLength: 0) + Text("\(itemCount)").font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + .padding(.horizontal, 10).frame(width: 222, height: 38) + } + .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.primary.opacity(isHovered ? 0.28 : 0.10), lineWidth: 1) + } + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .accessibilityLabel("打开媒体库 \(library.name),共 \(itemCount) 项") + } +} + +private struct MediaLandscapeCard: View { + let item: MediaLibraryItem + let onOpen: () -> Void + let onPlay: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: onOpen) { + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .bottomLeading) { + MediaArtwork(data: item.backdropData ?? item.posterData, icon: "film.fill", contentMode: .fill) + .frame(width: 244, height: 137) + .clipped() + LinearGradient(colors: [.clear, .black.opacity(0.70)], startPoint: .center, endPoint: .bottom) + VStack(alignment: .leading, spacing: 2) { + Text(item.title).font(.callout.weight(.semibold)).foregroundStyle(.white).lineLimit(1) + Text(item.year.isEmpty ? (item.mediaKind == .tv ? "电视剧" : "电影") : item.year) + .font(.caption).foregroundStyle(.white.opacity(0.74)) + } + .padding(10) + if isHovered && !item.file.isDirectory { + Image(systemName: "play.fill") + .font(.headline).foregroundStyle(.black) + .frame(width: 36, height: 36) + .background(.white, in: Circle()) + .padding(10) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + } + } + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.primary.opacity(isHovered ? 0.30 : 0.10), lineWidth: 1) + } + } + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + .simultaneousGesture(TapGesture(count: 2).onEnded(onPlay)) + .accessibilityLabel("打开 \(item.title)") + } +} + private struct MediaCollectionCard: View { let collection: MediaCollectionGroup let onOpen: () -> Void @@ -579,10 +2368,11 @@ private struct MediaPosterCard: View { ZStack(alignment: .bottomTrailing) { MediaArtwork(data: item.posterData, icon: "film.fill", contentMode: .fill) .aspectRatio(2 / 3, contentMode: .fit).frame(maxWidth: .infinity).clipShape(RoundedRectangle(cornerRadius: 6)) - if isHovered { Image(systemName: "play.fill").font(.title3).frame(width: 42, height: 42).background(.white, in: Circle()).foregroundStyle(.black).padding(10) } + if isHovered && !item.file.isDirectory { Image(systemName: "play.fill").font(.title3).frame(width: 42, height: 42).background(.white, in: Circle()).foregroundStyle(.black).padding(10) } + else if item.file.isDirectory { Text("原盘").font(.caption.weight(.bold)).padding(.horizontal, 8).padding(.vertical, 5).background(.black.opacity(0.74), in: Capsule()).padding(8) } else if resourceCount > 1 { Text(item.mediaKind == .tv ? "\(resourceCount) 集" : "\(resourceCount) 版本").font(.caption.weight(.bold)).padding(.horizontal, 8).padding(.vertical, 5).background(.black.opacity(0.74), in: Capsule()).padding(8) } } - .overlay { RoundedRectangle(cornerRadius: 6).stroke(isSelected ? AppTheme.orange : Color.primary.opacity(isHovered ? 0.34 : 0.10), lineWidth: isSelected ? 2 : 1) } + .overlay { RoundedRectangle(cornerRadius: 6).stroke(isSelected ? Color.blue : Color.primary.opacity(isHovered ? 0.34 : 0.10), lineWidth: isSelected ? 2 : 1) } Text(item.title).font(.callout.weight(.semibold)).lineLimit(2).frame(height: 36, alignment: .topLeading) HStack(spacing: 6) { Text(item.year.isEmpty ? "视频" : item.year) @@ -597,6 +2387,21 @@ private struct MediaPosterCard: View { } } +private struct MediaTheaterBackdrop: View { + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + LinearGradient( + colors: colorScheme == .dark + ? [Color(red: 0.045, green: 0.047, blue: 0.052), Color(red: 0.065, green: 0.067, blue: 0.072)] + : [Color(red: 0.955, green: 0.96, blue: 0.97), Color(red: 0.91, green: 0.925, blue: 0.945)], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + } +} + private struct MediaPosterWallSkeleton: View { let columns: [GridItem] let count: Int diff --git a/guangya_mac/MediaLibraryStore.swift b/guangya_mac/MediaLibraryStore.swift index ad20408..96b025e 100644 --- a/guangya_mac/MediaLibraryStore.swift +++ b/guangya_mac/MediaLibraryStore.swift @@ -3,8 +3,22 @@ import SQLite3 enum MediaLibraryStoreError: LocalizedError { case writeFailed + case backupFailed - var errorDescription: String? { "影视库数据库写入失败,未开始云端写回" } + var errorDescription: String? { + switch self { + case .writeFailed: return "影视库数据库写入失败,未开始云端写回" + case .backupFailed: return "影视库缓存备份文件无效或无法写入" + } + } +} + +struct MediaLibraryStatistics: Sendable, Equatable { + var total = 0 + var movies = 0 + var series = 0 + var unmatched = 0 + var collections = 0 } actor MediaLibraryStore { @@ -31,6 +45,7 @@ actor MediaLibraryStore { root_path TEXT NOT NULL, kind TEXT NOT NULL, recursive INTEGER NOT NULL DEFAULT 1, + minimum_size_mb INTEGER NOT NULL DEFAULT 50, updated_at REAL ); """) @@ -78,7 +93,10 @@ actor MediaLibraryStore { Self.execute(database, "ALTER TABLE media_items ADD COLUMN has_chinese_subtitle INTEGER NOT NULL DEFAULT 0;") Self.execute(database, "ALTER TABLE media_items ADD COLUMN collection_id INTEGER;") Self.execute(database, "ALTER TABLE media_items ADD COLUMN collection_name TEXT;") + Self.execute(database, "ALTER TABLE media_libraries ADD COLUMN minimum_size_mb INTEGER NOT NULL DEFAULT 50;") Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_library_kind ON media_items(library_id, media_kind);") + Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_library_title ON media_items(library_id, title COLLATE NOCASE);") + Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_title ON media_items(title COLLATE NOCASE);") Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_gcid ON media_items(gcid);") Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_collection ON media_items(library_id, collection_id);") Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_library_sources_library ON media_library_sources(library_id, sort_order);") @@ -86,20 +104,51 @@ actor MediaLibraryStore { deinit { if let database { sqlite3_close(database) } } + func isEmpty() -> Bool { + guard let database else { return true } + return Self.scalarCount(database, sql: "SELECT COUNT(*) FROM media_libraries;") == 0 && + Self.scalarCount(database, sql: "SELECT COUNT(*) FROM media_items;") == 0 + } + + func exportBackup(to url: URL) throws { + guard let database else { throw MediaLibraryStoreError.backupFailed } + try? FileManager.default.removeItem(at: url) + var destination: OpaquePointer? + guard sqlite3_open_v2(url.path, &destination, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK, + let destination else { throw MediaLibraryStoreError.backupFailed } + defer { sqlite3_close(destination) } + guard let backup = sqlite3_backup_init(destination, "main", database, "main") else { throw MediaLibraryStoreError.backupFailed } + defer { sqlite3_backup_finish(backup) } + guard sqlite3_backup_step(backup, -1) == SQLITE_DONE else { throw MediaLibraryStoreError.backupFailed } + } + + func importBackup(from url: URL) throws { + guard let database else { throw MediaLibraryStoreError.backupFailed } + var source: OpaquePointer? + guard sqlite3_open_v2(url.path, &source, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK, + let source else { throw MediaLibraryStoreError.backupFailed } + defer { sqlite3_close(source) } + guard let backup = sqlite3_backup_init(database, "main", source, "main") else { throw MediaLibraryStoreError.backupFailed } + defer { sqlite3_backup_finish(backup) } + guard sqlite3_backup_step(backup, -1) == SQLITE_DONE else { throw MediaLibraryStoreError.backupFailed } + Self.execute(database, "PRAGMA foreign_keys=ON;") + } + func libraries() -> [MediaLibraryDefinition] { guard let database else { return [] } var statement: OpaquePointer? - let sql = "SELECT id, name, root_id, root_path, kind, recursive, updated_at FROM media_libraries ORDER BY name COLLATE NOCASE;" + let sql = "SELECT id, name, root_id, root_path, kind, recursive, minimum_size_mb, updated_at FROM media_libraries ORDER BY name COLLATE NOCASE;" guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [] } defer { sqlite3_finalize(statement) } var values: [MediaLibraryDefinition] = [] while sqlite3_step(statement) == SQLITE_ROW { guard let id = text(statement, 0), let name = text(statement, 1), let rootPath = text(statement, 3), let rawKind = text(statement, 4), let kind = MediaLibraryKind(rawValue: rawKind) else { continue } - let epoch = integer(statement, 6) + let minimumSizeMB = Int(integer(statement, 6) ?? 50) + let epoch = integer(statement, 7) let sources = sources(libraryID: id) let resolvedSources = sources.isEmpty ? [MediaLibrarySource(id: "\(id)-legacy", rootID: text(statement, 2), path: rootPath)] : sources - values.append(MediaLibraryDefinition(id: id, name: name, sources: resolvedSources, kind: kind, recursive: sqlite3_column_int(statement, 5) != 0, updatedAt: epoch.map { Date(timeIntervalSince1970: TimeInterval($0)) })) + values.append(MediaLibraryDefinition(id: id, name: name, sources: resolvedSources, kind: kind, recursive: sqlite3_column_int(statement, 5) != 0, minimumSizeMB: minimumSizeMB, updatedAt: epoch.map { Date(timeIntervalSince1970: TimeInterval($0)) })) } return values } @@ -108,11 +157,11 @@ actor MediaLibraryStore { guard let database else { return } var statement: OpaquePointer? let sql = """ - INSERT INTO media_libraries (id, name, root_id, root_path, kind, recursive, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + INSERT INTO media_libraries (id, name, root_id, root_path, kind, recursive, minimum_size_mb, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(id) DO UPDATE SET name=excluded.name, root_id=excluded.root_id, root_path=excluded.root_path, kind=excluded.kind, recursive=excluded.recursive, - updated_at=excluded.updated_at; + minimum_size_mb=excluded.minimum_size_mb, updated_at=excluded.updated_at; """ guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return } defer { sqlite3_finalize(statement) } @@ -120,7 +169,8 @@ actor MediaLibraryStore { bind(library.id, statement, 1); bind(library.name, statement, 2); bind(primarySource?.rootID, statement, 3) bind(primarySource?.path ?? "未配置目录", statement, 4); bind(library.kind.rawValue, statement, 5) sqlite3_bind_int(statement, 6, library.recursive ? 1 : 0) - if let updatedAt = library.updatedAt { sqlite3_bind_double(statement, 7, updatedAt.timeIntervalSince1970) } else { sqlite3_bind_null(statement, 7) } + sqlite3_bind_int(statement, 7, Int32(library.minimumSizeMB)) + if let updatedAt = library.updatedAt { sqlite3_bind_double(statement, 8, updatedAt.timeIntervalSince1970) } else { sqlite3_bind_null(statement, 8) } sqlite3_step(statement) replaceSources(library.sources, libraryID: library.id) } @@ -135,17 +185,122 @@ actor MediaLibraryStore { } func items(libraryID: String) -> [MediaLibraryItem] { + loadItems(libraryID: libraryID, limit: nil, offset: 0, includeBackdrop: true) + } + + /// Poster-wall reads deliberately omit full backdrops. Background artwork + /// is substantially larger than a poster and decoding every row before the + /// first view appears made even indexed libraries feel slow. + func itemPage(libraryID: String?, limit: Int, offset: Int) -> [MediaLibraryItem] { + loadItems(libraryID: libraryID, limit: max(1, limit), offset: max(0, offset), includeBackdrop: false) + } + + /// Migrates cache entries created before zero-padded disc streams were + /// filtered at scan time. This intentionally reads only IDs and names — no + /// poster/backdrop BLOBs are materialised during cleanup. + func removeZeroPaddedMediaEntries() -> [(libraryID: String, fileID: String)] { guard let database else { return [] } + var select: OpaquePointer? + let sql = "SELECT library_id, file_id, cloud_name FROM media_items WHERE file_type = 2;" + guard sqlite3_prepare_v2(database, sql, -1, &select, nil) == SQLITE_OK, let select else { return [] } + defer { sqlite3_finalize(select) } + var values: [(libraryID: String, fileID: String)] = [] + while sqlite3_step(select) == SQLITE_ROW { + guard let libraryID = text(select, 0), let fileID = text(select, 1), let name = text(select, 2) else { continue } + let stem = (name as NSString).deletingPathExtension + if stem.range(of: "^0{2,}\\d+$", options: .regularExpression) != nil { + values.append((libraryID, fileID)) + } + } + guard !values.isEmpty else { return [] } + Self.execute(database, "BEGIN IMMEDIATE TRANSACTION;") + var delete: OpaquePointer? + guard sqlite3_prepare_v2(database, "DELETE FROM media_items WHERE library_id = ?1 AND file_id = ?2;", -1, &delete, nil) == SQLITE_OK, let delete else { + Self.execute(database, "ROLLBACK;") + return [] + } + defer { sqlite3_finalize(delete) } + for value in values { + sqlite3_reset(delete); sqlite3_clear_bindings(delete) + bind(value.libraryID, delete, 1); bind(value.fileID, delete, 2) + guard sqlite3_step(delete) == SQLITE_DONE else { Self.execute(database, "ROLLBACK;"); return [] } + } + return Self.execute(database, "COMMIT;") ? values : [] + } + + /// Lightweight aggregation for navigation badges. It touches only indexed + /// scalar columns and never materialises media rows or artwork BLOBs. + func statistics() -> MediaLibraryStatistics { + guard let database else { return .init() } var statement: OpaquePointer? + let sql = """ + SELECT COUNT(*), + COALESCE(SUM(CASE WHEN media_kind = 'movie' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN media_kind = 'tv' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN tmdb_id IS NULL THEN 1 ELSE 0 END), 0), + COUNT(DISTINCT CASE + WHEN collection_id IS NOT NULL THEN 'id:' || collection_id + WHEN collection_name IS NOT NULL AND collection_name <> '' THEN 'name:' || collection_name + END) + FROM media_items; + """ + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return .init() } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { return .init() } + return MediaLibraryStatistics( + total: Int(sqlite3_column_int64(statement, 0)), + movies: Int(sqlite3_column_int64(statement, 1)), + series: Int(sqlite3_column_int64(statement, 2)), + unmatched: Int(sqlite3_column_int64(statement, 3)), + collections: Int(sqlite3_column_int64(statement, 4)) + ) + } + + func statisticsByLibraryID() -> [String: MediaLibraryStatistics] { + guard let database else { return [:] } + var statement: OpaquePointer? + let sql = """ + SELECT library_id, COUNT(*), + COALESCE(SUM(CASE WHEN media_kind = 'movie' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN media_kind = 'tv' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN tmdb_id IS NULL THEN 1 ELSE 0 END), 0), + COUNT(DISTINCT CASE + WHEN collection_id IS NOT NULL THEN 'id:' || collection_id + WHEN collection_name IS NOT NULL AND collection_name <> '' THEN 'name:' || collection_name + END) + FROM media_items GROUP BY library_id; + """ + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [:] } + defer { sqlite3_finalize(statement) } + var values: [String: MediaLibraryStatistics] = [:] + while sqlite3_step(statement) == SQLITE_ROW { + guard let libraryID = text(statement, 0) else { continue } + values[libraryID] = MediaLibraryStatistics( + total: Int(sqlite3_column_int64(statement, 1)), + movies: Int(sqlite3_column_int64(statement, 2)), + series: Int(sqlite3_column_int64(statement, 3)), + unmatched: Int(sqlite3_column_int64(statement, 4)), + collections: Int(sqlite3_column_int64(statement, 5)) + ) + } + return values + } + + private func loadItems(libraryID: String?, limit: Int?, offset: Int, includeBackdrop: Bool) -> [MediaLibraryItem] { + guard let database else { return [] } + var statement: OpaquePointer? + let scope = libraryID == nil ? "" : "WHERE library_id = ?1" + let backdropColumn = includeBackdrop ? "backdrop" : "NULL AS backdrop" + let pagination = limit.map { " LIMIT \($0) OFFSET \(offset)" } ?? "" let sql = """ SELECT file_id, resource_path, cloud_name, file_size, gcid, file_type, tmdb_id, - media_kind, title, original_title, release_date, overview, poster, backdrop, + media_kind, title, original_title, release_date, overview, poster, \(backdropColumn), has_chinese_audio, has_chinese_subtitle, collection_id, collection_name - FROM media_items WHERE library_id = ?1 ORDER BY title COLLATE NOCASE; + FROM media_items \(scope) ORDER BY title COLLATE NOCASE\(pagination); """ guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [] } defer { sqlite3_finalize(statement) } - bind(libraryID, statement, 1) + if let libraryID { bind(libraryID, statement, 1) } var values: [MediaLibraryItem] = [] while sqlite3_step(statement) == SQLITE_ROW { guard let fileID = text(statement, 0), let path = text(statement, 1), let name = text(statement, 2), @@ -246,6 +401,13 @@ actor MediaLibraryStore { private func bind(_ value: String?, _ statement: OpaquePointer, _ index: Int32) { if let value { sqlite3_bind_text(statement, index, value, -1, transient) } else { sqlite3_bind_null(statement, index) } } + + private static func scalarCount(_ database: OpaquePointer, sql: String) -> Int { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return 0 } + defer { sqlite3_finalize(statement) } + return sqlite3_step(statement) == SQLITE_ROW ? Int(sqlite3_column_int64(statement, 0)) : 0 + } private func bind(_ value: Int64?, _ statement: OpaquePointer, _ index: Int32) { if let value { sqlite3_bind_int64(statement, index, value) } else { sqlite3_bind_null(statement, index) } } diff --git a/guangya_mac/Models.swift b/guangya_mac/Models.swift index bfe6913..167693c 100644 --- a/guangya_mac/Models.swift +++ b/guangya_mac/Models.swift @@ -72,6 +72,22 @@ enum JSONValue: Codable, Sendable, Equatable { return nil } + /// Resource IDs are returned as either strings or JSON numbers by different + /// file endpoints. Preserve the integer representation when walking nested + /// payloads so parent comparisons do not trigger a same-folder move. + func firstIDDeep(_ keys: [String]) -> String? { + if let value = firstID(keys) { return value } + guard let object = objectValue else { return nil } + let preferredKeys = ["data", "result", "user", "profile", "payload"] + for key in preferredKeys { + if let value = object[key]?.firstIDDeep(keys) { return value } + } + for (key, value) in object where !preferredKeys.contains(key) { + if let found = value.firstIDDeep(keys) { return found } + } + return nil + } + // API responses are not consistent about whether payload fields are nested in `data`. func firstStringDeep(_ keys: [String]) -> String? { if let value = firstString(keys) { return value } @@ -181,6 +197,19 @@ struct CloudFile: Identifiable, Hashable, Sendable { return formatter.string(fromByteCount: size) } + init(id: String, name: String, isDirectory: Bool, size: Int64? = nil, gcid: String? = nil, subDirectoryCount: Int? = nil, subFileCount: Int? = nil, modifiedAt: String = "", cloudPath: String = "", fileType: Int = 0) { + self.id = id + self.name = name + self.isDirectory = isDirectory + self.size = size + self.gcid = gcid + self.subDirectoryCount = subDirectoryCount + self.subFileCount = subFileCount + self.modifiedAt = modifiedAt + self.cloudPath = cloudPath + self.fileType = fileType + } + init?(json: JSONValue) { guard let object = json.objectValue, let name = json.firstString(["name", "fileName", "resName"]), @@ -241,6 +270,85 @@ struct FolderPath: Identifiable, Hashable, Sendable { let name: String } +struct FastTransferEntry: Identifiable, Hashable, Sendable, Codable { + let id: UUID + let path: String + let size: Int64 + let etag: String? + let gcid: String? + + init(id: UUID = UUID(), path: String, size: Int64, etag: String? = nil, gcid: String? = nil) { + self.id = id + self.path = path + self.size = size + self.etag = etag + self.gcid = gcid + } + + var name: String { (path as NSString).lastPathComponent } + var directoryPath: String { (path as NSString).deletingLastPathComponent } + var hashKind: String { etag?.isEmpty == false ? "MD5" : "GCID" } +} + +struct FastTransferProgress: Sendable, Codable { + var total = 0 + var completed = 0 + var imported = 0 + var skipped = 0 + var failed = 0 + var cancelled = 0 + var currentName = "" + /// Entries undergoing target-folder preparation or an in-flight flash upload. + /// Keeping this in the progress model lets the queue show useful work before + /// a terminal result exists (for example while checking duplicate names). + var activeEntryIDs: Set = [] + + var fraction: Double { total == 0 ? 0 : Double(completed + failed + cancelled) / Double(total) } + var processed: Int { completed + failed + cancelled } +} + +struct FastTransferResult: Identifiable, Sendable, Codable { + let id: UUID + let entry: FastTransferEntry + let state: State + let message: String + let createdAt: Date + let taskID: String? + let targetID: String? + let details: [String] + /// Points to the failed record this attempt was manually retried from. + let retryOf: UUID? + + enum State: String, Sendable, Codable { + case imported + case skipped + case failed + case cancelled + } + + init( + id: UUID = UUID(), + entry: FastTransferEntry, + state: State, + message: String, + createdAt: Date = Date(), + taskID: String?, + targetID: String?, + details: [String], + retryOf: UUID? + ) { + self.id = id + self.entry = entry + self.state = state + self.message = message + self.createdAt = createdAt + self.taskID = taskID + self.targetID = targetID + self.details = details + self.retryOf = retryOf + } +} + struct UserProfile: Sendable { let name: String let phone: String @@ -519,7 +627,10 @@ struct MediaLibrarySource: Identifiable, Hashable, Sendable { enum MediaLibraryDestination: Equatable, Sendable { case all case library(String) + case management + case categories case tasks + case tmdb } enum MediaLibraryKind: String, CaseIterable, Identifiable, Codable, Sendable { @@ -544,12 +655,62 @@ enum MediaLibraryKind: String, CaseIterable, Identifiable, Codable, Sendable { } } +struct MediaCategoryRule: Identifiable, Hashable, Codable, Sendable { + let id: String + var name: String + var mediaKind: TMDBMediaKind + var genreIDs: [Int] + var languages: [String] + var isFallback: Bool + + init(id: String = UUID().uuidString, name: String, mediaKind: TMDBMediaKind, genreIDs: [Int] = [], languages: [String] = [], isFallback: Bool = false) { + self.id = id + self.name = name + self.mediaKind = mediaKind + self.genreIDs = genreIDs + self.languages = languages + self.isFallback = isFallback + } + + /// Library organization is intentionally based on TMDB's original language. + /// Genre data is retained for compatibility with saved rules, but cannot + /// redirect a title into a category. + func matches(mediaKind: TMDBMediaKind, genreIDs _: Set, language: String) -> Bool { + guard self.mediaKind == mediaKind else { return false } + if isFallback { return true } + return !languages.isEmpty && languages.contains(language.lowercased()) + } + + static let defaults: [MediaCategoryRule] = [ + MediaCategoryRule(id: "movie-chinese", name: "国产电影", mediaKind: .movie, languages: ["zh", "cn", "yue"]), + MediaCategoryRule(id: "movie-japanese", name: "日韩电影", mediaKind: .movie, languages: ["ja", "ko", "th"]), + MediaCategoryRule(id: "movie-western", name: "欧美电影", mediaKind: .movie, languages: ["en"]), + MediaCategoryRule(id: "movie-other", name: "其他电影", mediaKind: .movie, isFallback: true), + MediaCategoryRule(id: "tv-chinese", name: "国产剧集", mediaKind: .tv, languages: ["zh", "cn", "yue"]), + MediaCategoryRule(id: "tv-japanese", name: "日韩剧集", mediaKind: .tv, languages: ["ja", "ko", "th"]), + MediaCategoryRule(id: "tv-western", name: "欧美剧集", mediaKind: .tv, languages: ["en"]), + MediaCategoryRule(id: "tv-other", name: "其他剧集", mediaKind: .tv, isFallback: true) + ] + + static func load() -> [MediaCategoryRule] { + guard let data = UserDefaults.standard.data(forKey: "guangya.mediaCategoryRules"), + let rules = try? JSONDecoder().decode([MediaCategoryRule].self, from: data), !rules.isEmpty else { return defaults } + return rules + } + + static func save(_ rules: [MediaCategoryRule]) { + guard let data = try? JSONEncoder().encode(rules) else { return } + UserDefaults.standard.set(data, forKey: "guangya.mediaCategoryRules") + } +} + struct MediaLibraryDefinition: Identifiable, Hashable, Sendable { let id: String var name: String var sources: [MediaLibrarySource] var kind: MediaLibraryKind var recursive: Bool + var minimumSizeMB: Int var updatedAt: Date? var rootID: String? { sources.first?.rootID } @@ -558,28 +719,147 @@ struct MediaLibraryDefinition: Identifiable, Hashable, Sendable { return sources.isEmpty ? "未配置目录" : "\(sources.count) 个媒体目录" } - init(id: String, name: String, sources: [MediaLibrarySource], kind: MediaLibraryKind, recursive: Bool, updatedAt: Date?) { + init(id: String, name: String, sources: [MediaLibrarySource], kind: MediaLibraryKind, recursive: Bool, minimumSizeMB: Int = 50, updatedAt: Date?) { self.id = id self.name = name self.sources = sources self.kind = kind self.recursive = recursive + self.minimumSizeMB = max(0, minimumSizeMB) self.updatedAt = updatedAt } - init(id: String, name: String, rootID: String?, rootPath: String, kind: MediaLibraryKind, recursive: Bool, updatedAt: Date?) { - self.init(id: id, name: name, sources: [MediaLibrarySource(rootID: rootID, path: rootPath)], kind: kind, recursive: recursive, updatedAt: updatedAt) + init(id: String, name: String, rootID: String?, rootPath: String, kind: MediaLibraryKind, recursive: Bool, minimumSizeMB: Int = 50, updatedAt: Date?) { + self.init(id: id, name: name, sources: [MediaLibrarySource(rootID: rootID, path: rootPath)], kind: kind, recursive: recursive, minimumSizeMB: minimumSizeMB, updatedAt: updatedAt) } } -struct MediaLibraryScanProgress: Sendable { +struct MediaLibraryScanProgress: Codable, Sendable { var phase = "" var completed = 0 var total = 0 } +enum MediaLibraryTaskState: String, Codable, Sendable { + case queued + case scanning + case writing + case completed + case cancelled + case failed + + var title: String { + switch self { + case .queued: return "等待开始" + case .scanning: return "正在刮削" + case .writing: return "正在写回" + case .completed: return "已完成" + case .cancelled: return "已停止" + case .failed: return "失败" + } + } + + var icon: String { + switch self { + case .queued: return "clock" + case .scanning: return "magnifyingglass" + case .writing: return "square.and.arrow.down" + case .completed: return "checkmark.circle.fill" + case .cancelled: return "stop.circle.fill" + case .failed: return "exclamationmark.circle.fill" + } + } +} + +struct MediaLibraryTaskLog: Identifiable, Codable, Sendable { + let id: UUID + let date: Date + let message: String + + init(date: Date = Date(), message: String) { + self.id = UUID() + self.date = date + self.message = message + } +} + +struct MediaLibraryTaskCheckpoint: Codable, Sendable { + var discoveredFileIDs: Set + var completedFileIDs: Set + var writtenFileIDs: Set + + init(discoveredFileIDs: Set = [], completedFileIDs: Set = [], writtenFileIDs: Set = []) { + self.discoveredFileIDs = discoveredFileIDs + self.completedFileIDs = completedFileIDs + self.writtenFileIDs = writtenFileIDs + } + + private enum CodingKeys: String, CodingKey { + case discoveredFileIDs + case completedFileIDs + case writtenFileIDs + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + discoveredFileIDs = try container.decodeIfPresent(Set.self, forKey: .discoveredFileIDs) ?? [] + completedFileIDs = try container.decodeIfPresent(Set.self, forKey: .completedFileIDs) ?? [] + writtenFileIDs = try container.decodeIfPresent(Set.self, forKey: .writtenFileIDs) ?? [] + } +} + +struct MediaLibraryTask: Identifiable, Codable, Sendable { + let id: UUID + let libraryID: String + let libraryName: String + let startedAt: Date + var updatedAt: Date + var state: MediaLibraryTaskState + var progress: MediaLibraryScanProgress + var currentScrapingName: String? + var currentStoringName: String? + var currentWritingName: String? + var failureReason: String? + var logs: [MediaLibraryTaskLog] + var checkpoint: MediaLibraryTaskCheckpoint? + + init(library: MediaLibraryDefinition) { + id = UUID() + libraryID = library.id + libraryName = library.name + startedAt = Date() + updatedAt = startedAt + state = .queued + progress = MediaLibraryScanProgress(phase: "等待开始") + currentScrapingName = nil + currentStoringName = nil + currentWritingName = nil + failureReason = nil + logs = [MediaLibraryTaskLog(message: "任务已创建,等待扫描「\(library.name)」")] + checkpoint = MediaLibraryTaskCheckpoint() + } + + var isActive: Bool { [.queued, .scanning, .writing].contains(state) } + + static func loadHistory() -> [MediaLibraryTask] { + guard let data = UserDefaults.standard.data(forKey: "guangya.mediaLibraryTaskHistory"), + let tasks = try? JSONDecoder().decode([MediaLibraryTask].self, from: data) else { return [] } + return tasks.map { task in + var restored = task + if restored.isActive { + restored.state = .cancelled + restored.progress.phase = "应用重启,任务未继续执行" + restored.logs.append(MediaLibraryTaskLog(message: "应用重启,未完成任务已停止")) + restored.updatedAt = Date() + } + return restored + } + } +} + struct MediaSidecarFiles: Sendable { let parentID: String? + let mediaFolderName: String? let nfo: CloudFile? let poster: CloudFile? let backdrop: CloudFile? diff --git a/guangya_mac/WorkspaceShell.swift b/guangya_mac/WorkspaceShell.swift index 48d5bfd..7d4e6d5 100644 --- a/guangya_mac/WorkspaceShell.swift +++ b/guangya_mac/WorkspaceShell.swift @@ -1,10 +1,11 @@ import SwiftUI import UniformTypeIdentifiers -enum WorkspaceTool: String, CaseIterable, Identifiable { +enum WorkspaceTool: String, CaseIterable, Identifiable, Hashable { case media = "影视库" case scan = "文件扫描与清理" case rename = "批量重命名" + case fastTransfer = "秒传工具" case tmdb = "TMDB 整理" case settings = "工作区设置" @@ -14,6 +15,7 @@ enum WorkspaceTool: String, CaseIterable, Identifiable { case .media: return "play.tv.fill" case .scan: return "magnifyingglass.circle.fill" case .rename: return "character.cursor.ibeam" + case .fastTransfer: return "bolt.badge.clock" case .tmdb: return "film.stack" case .settings: return "gearshape" } @@ -23,12 +25,50 @@ enum WorkspaceTool: String, CaseIterable, Identifiable { case .media: return "海报墙、媒体详情与播放入口" case .scan: return "空文件夹、重复文件与相似文件夹统一扫描" case .rename: return "按字符、正则和组合规则批量修改名称" + case .fastTransfer: return "导入秒传 JSON,或从本地文件生成 GCID JSON" case .tmdb: return "配置并执行媒体识别整理" case .settings: return "代理和本机工具配置" } } } +enum WorkspaceMode: String, CaseIterable, Identifiable { + case files = "光鸭云盘" + case media = "光鸭影视" + + var id: String { rawValue } + var icon: String { self == .files ? "folder" : "play.tv.fill" } +} + +private enum ToolbarSearchScope: Hashable { + case files + case media +} + +struct CompactHoverHint: ViewModifier { + let title: String + @State private var isHovering = false + + func body(content: Content) -> some View { + content + .onHover { isHovering = $0 } + .popover(isPresented: $isHovering, arrowEdge: .leading) { + Text(title) + .font(.callout.weight(.medium)) + .padding(.horizontal, 12).padding(.vertical, 8) + .fixedSize() + } + } +} + +extension View { + @ViewBuilder + func compactHoverHint(_ title: String, enabled: Bool = true) -> some View { + if enabled { modifier(CompactHoverHint(title: title)) } + else { self } + } +} + struct WorkspaceView: View { @ObservedObject var model: AppModel @State private var selectedFileIDs: Set = [] @@ -41,67 +81,126 @@ struct WorkspaceView: View { @State private var selectedTool: WorkspaceTool? @State private var folderName = "" @State private var columnVisibility: NavigationSplitViewVisibility = .all - @State private var previewFile: CloudFile? @State private var globalSearchText = "" + @State private var workspaceMode: WorkspaceMode = .files + @State private var isCompactWindow = false + @State private var sidebarWasAutoCollapsed = false + @State private var retainedTools: Set = [] + @State private var hasVisitedMediaMode = false + @State private var isToolbarSearchExpanded = false + @State private var isDualPanel = false + @State private var isWindowDropTarget = false + @FocusState private var toolbarSearchFocus: ToolbarSearchScope? private var selectedFile: CloudFile? { - selectedFileIDs.count == 1 ? model.files.first { $0.id == selectedFileIDs.first } : nil + guard selectedFileIDs.count == 1, let id = selectedFileIDs.first else { return nil } + return (model.files + model.globalFileSearchResults).first { $0.id == id } + } + + private var dualPanelContent: AnyView? { + guard isDualPanel, selectedTool == nil else { return nil } + return AnyView(DualPanelView(model: model)) + } + + private var filesBrowserContent: some View { + FilesBrowser( + model: model, + selectedFileIDs: $selectedFileIDs, + globalSearchText: $globalSearchText, + renameFile: $renameFile, + isCreatingFolder: $isCreatingFolder, + isImporting: $isImporting, + isShowingSettings: $isShowingSettings, + isShowingDuplicates: $isShowingDuplicates, + isDualPanel: $isDualPanel, + onPreview: { PlayerWindowCoordinator.shared.open(model: model, file: $0) }, + onOpenTool: { selectedTool = $0 }, + onShowInspector: { isInspectorPresented = true }, + dualPanelContent: dualPanelContent + ) + .frame(minWidth: 680, maxWidth: .infinity, maxHeight: .infinity) + .inspector(isPresented: $isInspectorPresented) { + FileInspector( + model: model, + file: selectedFile, + onPreview: { PlayerWindowCoordinator.shared.open(model: model, file: $0) }, + onClose: { isInspectorPresented = false } + ) + .inspectorColumnWidth(min: 280, ideal: 320, max: 420) + } } var body: some View { - ZStack { - AppBackdrop() - NavigationSplitView(columnVisibility: $columnVisibility) { - WorkspaceSidebar(model: model, selectedTool: $selectedTool, columnVisibility: $columnVisibility) - .navigationSplitViewColumnWidth(min: 210, ideal: 238, max: 280) - } detail: { - if let selectedTool { - ToolWorkspacePage(model: model, tool: selectedTool) - } else { - FilesBrowser( - model: model, - selectedFileIDs: $selectedFileIDs, - globalSearchText: $globalSearchText, - renameFile: $renameFile, - isCreatingFolder: $isCreatingFolder, - isImporting: $isImporting, - isShowingSettings: $isShowingSettings, - isShowingDuplicates: $isShowingDuplicates, - onPreview: { previewFile = $0 }, - onOpenTool: { selectedTool = $0 }, - onShowInspector: { isInspectorPresented = true } - ) - .frame(minWidth: 680, maxWidth: .infinity, maxHeight: .infinity) - .inspector(isPresented: $isInspectorPresented) { - FileInspector( - model: model, - file: selectedFile, - onPreview: { previewFile = $0 }, - onClose: { isInspectorPresented = false } - ) - .inspectorColumnWidth(min: 280, ideal: 320, max: 420) - } - } - } - .scrollContentBackground(.hidden) - .toolbar { - ToolbarItem(placement: .principal) { - AppTitleToolbar(model: model, selectedTool: selectedTool, searchText: $globalSearchText) - } - ToolbarItem(placement: .primaryAction) { - if selectedTool == nil { - Button { isInspectorPresented.toggle() } label: { - Image(systemName: "sidebar.trailing") + GeometryReader { geometry in + ZStack { + AppBackdrop() + ZStack { + NavigationSplitView(columnVisibility: $columnVisibility) { + WorkspaceSidebar(model: model, selectedTool: $selectedTool, isCompact: isCompactWindow) + .navigationSplitViewColumnWidth(min: isCompactWindow ? 64 : 210, ideal: isCompactWindow ? 64 : 238, max: isCompactWindow ? 64 : 280) + } detail: { + ZStack { + // Always render FilesBrowser — it provides the + // action bar (upload / toggle / refresh) and + // breadcrumb. In dual-panel mode its file-list + // content is replaced by DualPanelView. + filesBrowserContent + + ForEach(retainedToolList) { tool in + ToolWorkspacePage(model: model, tool: tool) + .opacity(selectedTool == tool ? 1 : 0) + .allowsHitTesting(selectedTool == tool) + .accessibilityHidden(selectedTool != tool) + } + } + } + .opacity(workspaceMode == .files ? 1 : 0) + .allowsHitTesting(workspaceMode == .files) + .accessibilityHidden(workspaceMode != .files) + + if hasVisitedMediaMode || workspaceMode == .media { + MediaLibraryPage(model: model) + .opacity(workspaceMode == .media ? 1 : 0) + .allowsHitTesting(workspaceMode == .media) + .accessibilityHidden(workspaceMode != .media) + } + } + .scrollContentBackground(.hidden) + .toolbar { + ToolbarItem(placement: .principal) { + WorkspaceModeSwitcher(selection: $workspaceMode, isCompact: isCompactWindow) + } + ToolbarItem(placement: .primaryAction) { + HStack(spacing: 6) { + if let scope = toolbarSearchScope { + toolbarSearchControl(scope) + } } - .help(isInspectorPresented ? "隐藏详情检查器" : "显示详情检查器") - .accessibilityLabel(isInspectorPresented ? "隐藏详情检查器" : "显示详情检查器") - .keyboardShortcut("i", modifiers: [.command, .option]) - .disabled(selectedFile == nil) } } } + .onDrop(of: [UTType.fileURL], isTargeted: $isWindowDropTarget) { providers in + for provider in providers { + _ = provider.loadObject(ofClass: URL.self) { url, _ in + guard let url else { return } + Task { @MainActor in await model.upload(url: url) } + } + } + return !providers.isEmpty + } + .overlay { + if isWindowDropTarget { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.orange, style: StrokeStyle(lineWidth: 3, dash: [8, 5])) + .fill(Color.orange.opacity(0.12)) + .padding(10) + .allowsHitTesting(false) + } + } + .onAppear { updateCompactLayout(for: geometry.size.width) } + .onChange(of: geometry.size.width) { _, width in updateCompactLayout(for: width) } } - .frame(minWidth: 1040, minHeight: 700) + .frame(minWidth: 860, minHeight: 640) .sheet(item: $renameFile) { file in RenameSheet(file: file) { name in Task { await model.rename(file, to: name) } } } .sheet(isPresented: $isCreatingFolder) { CreateFolderSheet(name: $folderName) { name in @@ -112,7 +211,6 @@ struct WorkspaceView: View { } .sheet(isPresented: $isShowingSettings) { WorkspaceSettings(model: model) } .sheet(isPresented: $isShowingDuplicates) { DuplicateResults(model: model) } - .sheet(item: $previewFile) { file in FilePreviewSheet(model: model, file: file) } .fileImporter(isPresented: $isImporting, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in switch result { case .success(let urls): if let url = urls.first { Task { await model.upload(url: url) } } @@ -126,13 +224,30 @@ struct WorkspaceView: View { if model.files.isEmpty && !model.isLoadingFiles { await model.refresh() } } .onChange(of: selectedTool) { _, tool in - if tool != nil { isInspectorPresented = false } + if let tool { + retainedTools.insert(tool) + isInspectorPresented = false + } + if tool != nil { collapseToolbarSearch(clearQuery: false) } + } + .onChange(of: workspaceMode) { _, mode in + selectedTool = nil + isInspectorPresented = false + collapseToolbarSearch(clearQuery: false) + if mode == .media { + hasVisitedMediaMode = true + } else if isCompactWindow { + columnVisibility = .detailOnly + sidebarWasAutoCollapsed = true + } } .onChange(of: model.section) { _, _ in selectedFileIDs = [] selectedTool = nil isInspectorPresented = false globalSearchText = "" + model.clearGlobalFileSearch() + collapseToolbarSearch(clearQuery: false) Task { await model.refresh() } } .onChange(of: model.files.map(\.id)) { _, ids in @@ -143,28 +258,134 @@ struct WorkspaceView: View { selectedFileIDs = [] isInspectorPresented = false globalSearchText = "" + model.clearGlobalFileSearch() + } + .onChange(of: model.globalFileSearchResults.map(\.id)) { _, ids in + selectedFileIDs.formIntersection(Set(ids)) + if selectedFileIDs.isEmpty { isInspectorPresented = false } + } + } + + private func updateCompactLayout(for width: CGFloat) { + let compact = width < 1120 + guard compact != isCompactWindow else { return } + isCompactWindow = compact + guard workspaceMode == .files else { return } + if sidebarWasAutoCollapsed { columnVisibility = .all; sidebarWasAutoCollapsed = false } + } + + private var retainedToolList: [WorkspaceTool] { + let activeTool = selectedTool.map { retainedTools.union([$0]) } ?? retainedTools + return WorkspaceTool.allCases.filter(activeTool.contains) + } + + private var toolbarSearchScope: ToolbarSearchScope? { + if workspaceMode == .media { return .media } + if selectedTool == nil, model.section == .files { return .files } + return nil + } + + @ViewBuilder + private func toolbarSearchControl(_ scope: ToolbarSearchScope) -> some View { + if isToolbarSearchExpanded { + HStack(spacing: 6) { + expandedToolbarSearchField(scope) + Button { collapseToolbarSearch(clearQuery: true) } label: { Image(systemName: "xmark") } + .buttonStyle(.plain) + .help("关闭搜索") + .accessibilityLabel("关闭搜索") + } + .padding(.horizontal, 8) + .frame(width: 286, height: 30) + .background(Color.primary.opacity(0.07), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + } else { + Button { expandToolbarSearch(scope) } label: { Image(systemName: "magnifyingglass") } + .help(scope == .files ? "搜索全盘文件和文件夹" : "搜索影视") + .accessibilityLabel(scope == .files ? "搜索全盘文件和文件夹" : "搜索影视") + } + } + + @ViewBuilder + private func expandedToolbarSearchField(_ scope: ToolbarSearchScope) -> some View { + switch scope { + case .files: + TextField("搜索全盘文件和文件夹", text: $globalSearchText) + .textFieldStyle(.plain) + .focused($toolbarSearchFocus, equals: .files) + .onChange(of: globalSearchText) { _, query in model.searchFilesGlobally(query) } + case .media: + TextField("搜索影视", text: $model.mediaLibrarySearchText) + .textFieldStyle(.plain) + .focused($toolbarSearchFocus, equals: .media) + } + } + + private func expandToolbarSearch(_ scope: ToolbarSearchScope) { + withAnimation(.easeInOut(duration: 0.18)) { isToolbarSearchExpanded = true } + DispatchQueue.main.async { toolbarSearchFocus = scope } + } + + private func collapseToolbarSearch(clearQuery: Bool) { + toolbarSearchFocus = nil + withAnimation(.easeInOut(duration: 0.18)) { isToolbarSearchExpanded = false } + guard clearQuery else { return } + switch toolbarSearchScope { + case .files: + globalSearchText = "" + model.clearGlobalFileSearch() + case .media: + model.mediaLibrarySearchText = "" + case nil: + break } } } -private struct AppTitleToolbar: View { - @ObservedObject var model: AppModel - let selectedTool: WorkspaceTool? - @Binding var searchText: String - - private var supportsSearch: Bool { - selectedTool == nil && [.files, .photos, .videos, .audio, .documents].contains(model.section) - } +private struct WorkspaceModeSwitcher: View { + @Binding var selection: WorkspaceMode + let isCompact: Bool + @Environment(\.colorScheme) private var colorScheme var body: some View { - HStack(spacing: 12) { - Label(selectedTool?.rawValue ?? model.section.rawValue, systemImage: selectedTool?.icon ?? model.section.icon) - .font(.headline.weight(.semibold)) - if supportsSearch { - TextField("搜索当前列表", text: $searchText) - .textFieldStyle(.roundedBorder) - .frame(width: 260) + HStack(spacing: 3) { + ForEach(WorkspaceMode.allCases) { mode in + modeButton(mode) } } + .padding(3) + .background(Color.primary.opacity(colorScheme == .dark ? 0.09 : 0.06), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .stroke(Color.primary.opacity(colorScheme == .dark ? 0.18 : 0.11), lineWidth: 1) + } + .fixedSize(horizontal: true, vertical: false) + .accessibilityElement(children: .contain) + .accessibilityLabel("工作模式") + } + + private func modeButton(_ mode: WorkspaceMode) -> some View { + let isSelected = selection == mode + let selectedBackground = isSelected ? Color.primary.opacity(colorScheme == .dark ? 0.16 : 0.09) : Color.clear + return Button { + withAnimation(.easeInOut(duration: 0.18)) { selection = mode } + } label: { + Group { + if isCompact { + Label(mode.rawValue, systemImage: mode.icon).labelStyle(.iconOnly) + } else { + Label(mode.rawValue, systemImage: mode.icon).labelStyle(.titleAndIcon) + } + } + .font(.callout.weight(isSelected ? .semibold : .medium)) + .foregroundStyle(isSelected ? Color.primary : Color.secondary) + .frame(minWidth: isCompact ? 34 : 102, minHeight: 30) + .padding(.horizontal, isCompact ? 2 : 10) + .background(selectedBackground, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + .help("切换到\(mode.rawValue)") + .compactHoverHint(mode.rawValue, enabled: isCompact) + .accessibilityLabel("切换到\(mode.rawValue)") + .accessibilityAddTraits(isSelected ? .isSelected : []) } } diff --git a/guangya_mac/guangya_macApp.swift b/guangya_mac/guangya_macApp.swift index f9fa902..a423b45 100644 --- a/guangya_mac/guangya_macApp.swift +++ b/guangya_mac/guangya_macApp.swift @@ -2,10 +2,19 @@ import SwiftUI @main struct guangya_macApp: App { + init() { + #if DEBUG + // The bundle is copied into the Debug app by the "Copy Injection Bundle" build phase. + let bundlePath = Bundle.main.path(forResource: "macOSInjection", ofType: "bundle") + Bundle(path: bundlePath ?? "")?.load() + #endif + } + var body: some Scene { WindowGroup { ContentView() } .windowToolbarStyle(.unified(showsTitle: false)) + .handlesExternalEvents(matching: []) } }