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