Files
guangya_client/guangya_mac/AppModel.swift
T

1558 lines
89 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"])
let subtitleExtensions: Set<String> = ["srt", "ass", "ssa", "sub", "vtt", "sup"]
let subtitles = ordinaryFiles.filter { file in
let lower = file.name.lowercased()
guard subtitleExtensions.contains((lower as NSString).pathExtension) else { return false }
let subtitleStem = (lower as NSString).deletingPathExtension
return subtitleStem == stem || subtitleStem.hasPrefix(stem + ".") || subtitleStem.hasPrefix(stem + "-") || subtitleStem.hasPrefix(stem + "_")
}
if nfo != nil || poster != nil || backdrop != nil || !subtitles.isEmpty {
sidecars[video.id] = MediaSidecarFiles(nfo: nfo, poster: poster, backdrop: backdrop, subtitles: subtitles)
}
}
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 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)
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 details = try? await api.tmdbDetails(id: candidate.id, mediaKind: candidate.mediaType, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
let localizedTitle = details?.firstString(["title", "name"]) ?? candidate.title
let originalTitle = details?.firstString(["original_title", "original_name"]) ?? candidate.originalTitle
let localizedOverview = details?.firstString(["overview"]) ?? candidate.overview
let localizedReleaseDate = details?.firstString(["release_date", "first_air_date"]) ?? candidate.releaseDate
let localizedPosterPath = details.flatMap { Self.preferredArtworkPath(in: $0, collection: "posters") } ?? details?.firstString(["poster_path"]) ?? candidate.posterPath
let localizedBackdropPath = details.flatMap { Self.preferredArtworkPath(in: $0, collection: "backdrops") } ?? details?.firstString(["backdrop_path"]) ?? candidate.backdropPath
let posterData: Data?
if let path = localizedPosterPath {
posterData = try? await api.tmdbImage(path: path, size: "w500", apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
} else {
posterData = nil
}
let backdropData: Data?
if request.index < 8, let path = localizedBackdropPath {
backdropData = try? await api.tmdbImage(path: path, size: "w1280", apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
} else {
backdropData = nil
}
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))
} 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, hasChineseAudio: fallback.hasChineseAudio, hasChineseSubtitle: fallback.hasChineseSubtitle)
}
let inferredKind: TMDBMediaKind?
switch fallbackKind {
case .movies: inferredKind = .movie
case .series: inferredKind = .tv
case .mixed: inferredKind = parsed.mediaKind
}
return MediaLibraryItem(file: file, tmdbID: parsed.tmdbID, title: parsed.title, originalTitle: parsed.originalTitle, mediaKind: parsed.mediaKind ?? inferredKind, releaseDate: parsed.releaseDate, overview: parsed.overview, posterData: loadedPoster, backdropData: loadedBackdrop, hasChineseAudio: parsed.hasChineseAudio || fallback.hasChineseAudio, hasChineseSubtitle: parsed.hasChineseSubtitle || fallback.hasChineseSubtitle)
}
private nonisolated static func mediaLanguageProfile(fileName: String, subtitles: [CloudFile]) -> (audio: Bool, subtitle: Bool) {
let lower = fileName.lowercased()
let audioMarkers = ["国语", "普通话", "国粤", "粤语", "mandarin", "cantonese", "chinese.audio", "chi.audio", "zh.audio"]
let subtitleMarkers = ["中文", "中字", "简中", "繁中", "双语", ".chs", ".cht", ".chi", ".zho", ".zh", "chinese", "bilingual"]
let hasAudio = audioMarkers.contains { lower.contains($0) }
let hasSubtitle = subtitleMarkers.contains { lower.contains($0) } || subtitles.contains { subtitle in
let name = subtitle.name.lowercased()
return subtitleMarkers.contains { name.contains($0) }
}
return (hasAudio, hasSubtitle)
}
private nonisolated static func preferredArtworkPath(in details: JSONValue, collection: String) -> String? {
let images = details["images"]?[collection]?.arrayValue ?? []
let languageOrder: [String?] = ["zh", "zh-CN", nil, "en"]
for language in languageOrder {
if let path = images.first(where: { image in
let code = image.firstString(["iso_639_1"])
return language == nil ? code == nil : code?.caseInsensitiveCompare(language!) == .orderedSame
})?.firstString(["file_path"]) { return path }
}
return images.first?.firstString(["file_path"])
}
private nonisolated static func sidecarData(for file: CloudFile, api: GuangyaAPI) async -> Data? {
guard let result = try? await api.downloadURL(fileID: file.id),
let value = result.firstStringDeep(["url", "downloadUrl", "download_url", "dlink"]),
let url = URL(string: value) else { return nil }
return try? await URLSession.shared.data(from: url).0
}
private nonisolated static func sidecarData(for file: CloudFile?, api: GuangyaAPI) async -> Data? {
guard let file else { return nil }
return await sidecarData(for: file, api: api)
}
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 25100 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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
}