Files
guangya_client/guangya_mac/AppModel.swift
T

480 lines
24 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 duplicateGroups: [DuplicateGroup] = []
@Published var similarFolders: [CloudFile] = []
@Published var detail: JSONValue?
@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 isAnalyzing = false
@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>?
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 {
isSignedIn = 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 }
await perform("正在刷新…") { [self] in await loadFiles() }
}
func openFolder(_ file: CloudFile) async {
guard file.isDirectory, !isLoadingFiles else { return }
folderPath.append(FolderPath(id: file.id, name: file.name))
await loadFiles()
}
func navigateToRoot() async {
guard !folderPath.isEmpty, !isLoadingFiles else { return }
folderPath.removeAll()
await loadFiles()
}
func navigateToFolder(at index: Int) async {
guard folderPath.indices.contains(index), !isLoadingFiles else { return }
folderPath.removeSubrange((index + 1)..<folderPath.count)
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 delete(_ file: CloudFile) async {
await perform("正在删除…") { [self] in _ = try await api.fsDelete(fileIDs: [file.id]); await loadFiles() }
}
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 download(file) }
}
func download(_ file: CloudFile) async {
await perform("正在准备下载…") { [self] in
let result = try await api.downloadURL(fileID: file.id)
guard let value = result.firstStringDeep(["url", "downloadUrl", "download_url", "dlink"]) else { throw GuangyaAPIError.missingField("下载地址") }
guard let url = URL(string: value) else { throw GuangyaAPIError.invalidResponse }
NSWorkspace.shared.open(url)
}
}
func rename(_ file: CloudFile, to name: String) async {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != file.name else { return }
await perform("正在重命名…") { [self] in _ = try await api.fsRename(fileID: file.id, newName: trimmed); await loadFiles() }
}
func 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 {
isBusy = true
statusMessage = "正在读取详情…"
do {
detail = try await api.fsDetail(fileID: file.id)
} catch {
var fallback: [String: JSONValue] = [
"fileId": .string(file.id),
"fileName": .string(file.name),
"dirType": .number(file.isDirectory ? 1 : 0),
"resType": .number(Double(file.fileType))
]
if let size = file.size { fallback["size"] = .number(Double(size)) }
if let gcid = file.gcid { fallback["gcid"] = .string(gcid) }
detail = .object(["fileInfo": .object(fallback), "message": .string("详情请求失败:\(error.localizedDescription),已显示当前列表详情")])
}
isBusy = false
statusMessage = ""
}
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 {
await perform("正在扫描空文件夹…") { [self] in
var emptyIDs: [String] = []
@MainActor func scan(parentID: String?) async throws {
let items = extractFiles(from: try await api.fsFiles(parentID: parentID))
for item in items where item.isDirectory {
let children = extractFiles(from: try await api.fsFiles(parentID: item.id))
if children.isEmpty { emptyIDs.append(item.id) }
else { try await scan(parentID: item.id) }
}
}
try await scan(parentID: nil)
for id in emptyIDs.reversed() { _ = try await api.fsDelete(fileIDs: [id]) }
lastActionMessage = emptyIDs.isEmpty ? "没有发现空文件夹" : "已清理 \(emptyIDs.count) 个空文件夹"
await loadFiles()
}
}
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 {
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"])
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 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()
UserDefaults.standard.removeObject(forKey: "guangya.accessToken")
UserDefaults.standard.removeObject(forKey: "guangya.refreshToken")
isSignedIn = false
files = []
folderPath = []
statusMessage = ""
}
private func completeLogin() {
let defaults = UserDefaults.standard
defaults.set(api.accessToken, forKey: "guangya.accessToken")
if let refresh = api.refreshTokenValue { defaults.set(refresh, forKey: "guangya.refreshToken") }
isSignedIn = true
Task { await loadAccount() }
}
private func loadAccount() async {
await api.prepareSession()
isLoadingFiles = true
defer { isLoadingFiles = false }
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 = extractFiles(from: result)
await enrichPageDetails()
} else {
errorMessage = "文件列表加载失败,请点击刷新重试"
}
}
private func loadFiles() async {
guard !isLoadingFiles else { return }
isLoadingFiles = true
defer { isLoadingFiles = false }
do {
let result = try await filesResponse()
files = extractFiles(from: result)
await enrichPageDetails()
if files.isEmpty { statusMessage = "当前文件夹暂无文件" }
else if statusMessage == "当前文件夹暂无文件" { statusMessage = "" }
} catch { errorMessage = error.localizedDescription }
}
private func filesResponse() async throws -> JSONValue {
switch section {
case .files: return try await api.fsFiles(parentID: folderPath.last?.id)
case .photos: return try await api.fsImageList()
case .videos: return try await api.fsVideoList()
case .documents: return try await api.fsDocumentList()
case .recycle: return try await api.fsRecycleFiles()
case .cloud, .shares: return .object([:])
}
}
private func tmdbMatch(for name: String) async throws -> TMDBMatch {
let query = name.replacingOccurrences(of: "\\.[^.]+$", with: "", options: .regularExpression).replacingOccurrences(of: "[._()\\[\\]{}-]+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines)
let response = try await api.tmdbSearch(query: query, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort)
guard let matches = response["results"]?.arrayValue?.compactMap(TMDBMatch.init), let match = matches.first else { throw GuangyaAPIError.missingField("TMDB 识别结果") }
return match
}
private func normalizedFolderName(_ value: String) -> String {
value.lowercased().replacingOccurrences(of: "[\\s._()\\[\\]{}-]+", with: "", options: .regularExpression)
}
private func enrichPageDetails() async {
let page = files
guard !page.isEmpty else { return }
isLoadingFolderSizes = true
defer { isLoadingFolderSizes = false }
let api = self.api
let details = await withTaskGroup(of: (String, Int64?, String?, Int64?).self, returning: [(String, Int64?, String?, Int64?)].self) { group in
for file in page {
group.addTask {
let detail = try? await api.fsDetail(fileID: file.id)
return (file.id, detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]), detail?.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]), detail?.firstInt64Deep(["utime", "ctime"]))
}
}
var result: [(String, Int64?, String?, Int64?)] = []
for await item in group { result.append(item) }
return result
}
var updatedFiles = files
for index in updatedFiles.indices {
guard let item = details.first(where: { $0.0 == updatedFiles[index].id }) else { continue }
if let size = item.1 { updatedFiles[index].size = size }
if let gcid = item.2 { updatedFiles[index].gcid = gcid }
if let epoch = item.3 { updatedFiles[index].modifiedAt = Self.formatDate(epoch) }
}
files = updatedFiles
}
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 func extractFiles(from value: JSONValue) -> [CloudFile] {
var result: [CloudFile] = []
func visit(_ value: JSONValue) {
if let file = CloudFile(json: value), !result.contains(where: { $0.id == file.id }) { 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 {
self.qrStatus = "二维码状态查询失败"
self.errorMessage = error.localizedDescription
return
}
}
if !Task.isCancelled && Date() >= deadline { self.qrStatus = "二维码已过期,请刷新" }
}
}
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(); if errorMessage.isEmpty { statusMessage = "" } }
catch { errorMessage = error.localizedDescription }
isBusy = false
}
}