From db5c46414caa9ab459dc5d6a3d0f0fea055c46e9 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Thu, 16 Jul 2026 09:50:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=E4=BA=91=E7=9B=98?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E6=8E=A5=E5=8F=A3=E4=B8=8ETMDB=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- guangya_mac/GuangyaAPI.swift | 209 ++++++++++++++++++++++----- guangya_mac/Models.swift | 267 ++++++++++++++++++++++++++++++++++- 2 files changed, 440 insertions(+), 36 deletions(-) diff --git a/guangya_mac/GuangyaAPI.swift b/guangya_mac/GuangyaAPI.swift index bd36b27..4a2f6f2 100644 --- a/guangya_mac/GuangyaAPI.swift +++ b/guangya_mac/GuangyaAPI.swift @@ -7,6 +7,7 @@ enum GuangyaAPIError: LocalizedError { case http(status: Int, message: String) case missingField(String) case noRefreshToken + case authorizationExpired var isDeviceAuthorizationPending: Bool { guard case .http(let status, let message) = self, status == 400 else { return false } @@ -20,6 +21,7 @@ enum GuangyaAPIError: LocalizedError { case .http(let status, let message): return "请求失败(\(status)):\(message)" case .missingField(let field): return "响应缺少字段:\(field)" case .noRefreshToken: return "没有可用的刷新令牌" + case .authorizationExpired: return "登录授权已失效" } } } @@ -40,9 +42,14 @@ final class GuangyaAPI: @unchecked Sendable { self.refreshTokenValue = refreshToken self.deviceID = deviceID let configuration = URLSessionConfiguration.default - configuration.timeoutIntervalForRequest = 15 - configuration.timeoutIntervalForResource = 30 - configuration.waitsForConnectivity = false + // Cloud listing/detail calls can be slow on congested networks. Let the + // system wait for a route and retry transient failures below instead of + // surfacing the default 15-second timeout to the user. + configuration.timeoutIntervalForRequest = 45 + configuration.timeoutIntervalForResource = 120 + configuration.waitsForConnectivity = true + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.httpMaximumConnectionsPerHost = 8 configuration.httpAdditionalHeaders = [ "Accept": "application/json, text/plain, */*", "Content-Type": "application/json", @@ -64,9 +71,45 @@ final class GuangyaAPI: @unchecked Sendable { return AuthTokens(accessToken: access, refreshToken: refreshTokenValue, expiresIn: expires) } - func prepareSession() async { - guard !accessToken.isEmpty, refreshTokenValue != nil, tokenExpiresAt == nil else { return } - _ = try? await refreshToken() + func prepareSession() async throws { + // A session with an access token starts optimistically and only refreshes + // after an actual authorization failure. With no access token, the stored + // refresh token is the only way to restore the session. + if accessToken.isEmpty { + guard refreshTokenValue != nil else { throw GuangyaAPIError.authorizationExpired } + try await refreshOrThrowAuthorizationExpired() + } + } + + func clearTokens() { + accessToken = "" + refreshTokenValue = nil + tokenExpiresAt = nil + } + + private func isInvalidRefreshToken(_ error: Error) -> Bool { + guard let apiError = error as? GuangyaAPIError else { return false } + switch apiError { + case .noRefreshToken: return true + case .http(_, let message): + let text = message.lowercased() + return text.contains("invalid_grant") || text.contains("invalid refresh") || + text.contains("refresh_token") || text.contains("refresh token") || + text.contains("token expired") || text.contains("refresh token已") + default: return false + } + } + + private func refreshOrThrowAuthorizationExpired() async throws { + do { + _ = try await refreshToken() + } catch { + if isInvalidRefreshToken(error) { + clearTokens() + throw GuangyaAPIError.authorizationExpired + } + throw error + } } // MARK: Authentication @@ -151,7 +194,7 @@ final class GuangyaAPI: @unchecked Sendable { } func downloadURL(fileID: String) async throws -> JSONValue { - try await apiRequest("/nd.bizuserres.s/v1/get_res_download_url", body: object(("fileId", fileID))) + try await apiRequest("/nd.bizuserres.s/v1/get_res_download_url", body: .object(["fileId": .string(fileID)])) } func taskStatus(taskID: String) async throws -> JSONValue { @@ -167,57 +210,77 @@ final class GuangyaAPI: @unchecked Sendable { return try await apiRequest("/userres/v1/file/get_file_list", body: .object(body)) } + func recentViewed(pageSize: Int = 100, cursor: String = "") async throws -> JSONValue { try await apiRequest("/userres/v1/get_user_action", body: object(("cursor", cursor), ("pageSize", pageSize))) } + func recentRestored(pageSize: Int = 100) async throws -> JSONValue { try await apiRequest("/userres/v1/get_restore_list", body: object(("pageSize", pageSize), ("orderBy", 2), ("sortType", 1))) } func fsImageList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [1], resType: 1) } func fsVideoList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [2], resType: 1) } + func fsAudioList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [3], resType: 1, needPlayRecord: true) } func fsDocumentList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [4], resType: 1) } func fsRecycleFiles() async throws -> JSONValue { try await fsFiles(orderBy: 10, dirType: 4) } func fsCreateDir(name: String, parentID: String? = nil, failIfNameExist: Bool = false) async throws -> JSONValue { - var body = ["dirName": JSONValue.string(name), "parentId": .string(parentID ?? "")] + var body = ["dirName": JSONValue.string(name), "parentId": apiIDOrEmpty(parentID)] if failIfNameExist { body["failIfNameExist"] = .bool(true) } return try await apiRequest("/nd.bizuserres.s/v1/file/create_dir", body: .object(body)) } - func fsCopy(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/copy_file", body: object(("fileIds", fileIDs), ("parentId", parentID ?? ""))) } - func fsMove(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/move_file", body: object(("fileIds", fileIDs), ("parentId", parentID ?? ""))) } - func fsDelete(fileIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/delete_file", body: object(("fileIds", fileIDs))) } - func fsRecycle(fileIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/recycle_file", body: object(("fileIds", fileIDs))) } + func fsCopy(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/copy_file", body: object(("fileIds", apiIDs(fileIDs)), ("parentId", apiIDOrEmpty(parentID)))) } + func fsMove(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/move_file", body: object(("fileIds", apiIDs(fileIDs)), ("parentId", apiIDOrEmpty(parentID)))) } + func fsDelete(fileIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/delete_file", body: object(("fileIds", apiIDs(fileIDs)))) } + func fsRecycle(fileIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/recycle_file", body: object(("fileIds", apiIDs(fileIDs)))) } func fsClearRecycleBin() async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/clear_recycle_bin") } - func fsRename(fileID: String, newName: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/rename", body: object(("fileId", fileID), ("newName", newName))) } - func fsDetail(fileID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_file_detail", body: object(("fileId", fileID))) } + func fsRename(fileID: String, newName: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/rename", body: object(("fileId", apiID(fileID)), ("newName", newName))) } + func fsDetail(fileID: String) async throws -> JSONValue { + // Build the payload explicitly so the 19-digit resource ID can never pass + // through the Any/NSNumber conversion path and lose its JSON string type. + try await apiRequest("/userres/v1/file/get_file_detail", body: .object(["fileId": .string(fileID)])) + } + + func vodDownloadURL(fileID: String, gcid: String) async throws -> JSONValue { + try await apiRequest( + "/userres/v1/file/get_vod_download_url", + body: .object(["fileId": .string(fileID), "gcid": .string(gcid)]) + ) + } func shareCreate(fileIDs: [String], title: String, validateDuration: Int = 0, shareType: Int = 1, code: String = "", autoFillCode: Bool = true, trafficLimit: String = "0", maxRestoreCount: Int = 0, downloadType: Int = 1) async throws -> JSONValue { - try await apiRequest("/nd.bizuserres.s/v1/share_file", body: object(("fileIds", fileIDs), ("title", title), ("validateDuration", validateDuration), ("shareType", shareType), ("code", code), ("autoFillCode", autoFillCode), ("trafficLimit", trafficLimit), ("maxRestoreCount", maxRestoreCount), ("downloadType", downloadType))) + try await apiRequest("/nd.bizuserres.s/v1/share_file", body: object(("fileIds", apiIDs(fileIDs)), ("title", title), ("validateDuration", validateDuration), ("shareType", shareType), ("code", code), ("autoFillCode", autoFillCode), ("trafficLimit", trafficLimit), ("maxRestoreCount", maxRestoreCount), ("downloadType", downloadType))) } func shareUserList(page: Int = 0, pageSize: Int = 50) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_list", body: object(("page", page), ("pageSize", pageSize), ("orderType", 1), ("sortType", 1))) } func shareUpdate(shareID: String, title: String, validateDuration: Int = 0, shareType: Int = 1, code: String = "", autoFillCode: Bool = true, trafficLimit: String = "0", maxRestoreCount: Int = 0, downloadType: Int = 1) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/update_share", body: object(("id", shareID), ("title", title), ("validateDuration", validateDuration), ("shareType", shareType), ("code", code), ("autoFillCode", autoFillCode), ("trafficLimit", trafficLimit), ("maxRestoreCount", maxRestoreCount), ("downloadType", downloadType))) } func shareDelete(ids: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/delete_share", body: object(("ids", ids))) } - func shareRestore(accessToken: String, fileIDs: [String], parentID: String = "") async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/restore_share", body: object(("accessToken", accessToken), ("fileIds", fileIDs), ("parentId", parentID))) } - func shareDownloadURL(fileID: String, accessToken: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_download_url", body: object(("fileId", fileID), ("accessToken", accessToken))) } - func shareFilesSize(accessToken: String, fileIDs: [String], download: Bool = true) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_files_size", body: object(("accessToken", accessToken), ("fileIds", fileIDs), ("download", download))) } + func shareRestore(accessToken: String, fileIDs: [String], parentID: String = "") async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/restore_share", body: object(("accessToken", accessToken), ("fileIds", apiIDs(fileIDs)), ("parentId", apiIDOrEmpty(parentID.isEmpty ? nil : parentID)))) } + func shareDownloadURL(fileID: String, accessToken: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_download_url", body: object(("fileId", apiID(fileID)), ("accessToken", accessToken))) } + func shareFilesSize(accessToken: String, fileIDs: [String], download: Bool = true) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_files_size", body: object(("accessToken", accessToken), ("fileIds", apiIDs(fileIDs)), ("download", download))) } func shareSummary(shareID: String) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_summary", body: object(("shareId", shareID))) } func shareAccessToken(shareID: String, code: String) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_access_token", body: object(("shareId", shareID), ("code", code))) } - func shareFilesList(accessToken: String, parentID: String = "", page: Int = 1, pageSize: Int = 50, orderBy: Int = 0, sortType: Int = 0) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_page_files_list", body: object(("accessToken", accessToken), ("parentId", parentID), ("page", page), ("pageSize", pageSize), ("orderBy", orderBy), ("sortType", sortType))) } + func shareFilesList(accessToken: String, parentID: String = "", page: Int = 1, pageSize: Int = 50, orderBy: Int = 0, sortType: Int = 0) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_page_files_list", body: object(("accessToken", accessToken), ("parentId", apiIDOrEmpty(parentID.isEmpty ? nil : parentID)), ("page", page), ("pageSize", pageSize), ("orderBy", orderBy), ("sortType", sortType))) } // MARK: Upload func uploadToken(name: String, fileSize: Int64, parentID: String? = nil, md5: String? = nil) async throws -> JSONValue { var res: [String: JSONValue] = ["fileSize": .number(Double(fileSize))] if let md5 { res["md5"] = .string(md5) } - return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(2), "name": .string(name), "res": .object(res), "parentId": .string(parentID ?? "")])) + return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(2), "name": .string(name), "res": .object(res), "parentId": apiIDOrEmpty(parentID)])) } func checkCanFlashUpload(taskID: String, gcid: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/check_can_flash_upload", body: object(("taskId", taskID), ("gcid", gcid))) } func uploadInfo(taskID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_info_by_task_id", body: object(("taskId", taskID))) } - func tmdbSearch(query: String, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue { - var components = URLComponents(string: "https://api.themoviedb.org/3/search/multi")! + func tmdbSearch(query: String, apiKey: String, mediaKind: TMDBMediaKind = .automatic, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue { + let endpoint = mediaKind == .movie ? "movie" : (mediaKind == .tv ? "tv" : "multi") + var components = URLComponents(string: "https://api.themoviedb.org/3/search/\(endpoint)")! components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "query", value: query), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "include_adult", value: "false")] var request = URLRequest(url: components.url!) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = 15 - configuration.timeoutIntervalForResource = 30 - configuration.waitsForConnectivity = false + // Cloud listing/detail calls can be slow on congested networks. Let the + // system wait for a route and retry transient failures below instead of + // surfacing the default 15-second timeout to the user. + configuration.timeoutIntervalForRequest = 45 + configuration.timeoutIntervalForResource = 120 + configuration.waitsForConnectivity = true + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.httpMaximumConnectionsPerHost = 8 let normalizedHost = proxyHost .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: "^https?://", with: "", options: .regularExpression) @@ -238,6 +301,39 @@ final class GuangyaAPI: @unchecked Sendable { return try JSONDecoder().decode(JSONValue.self, from: data) } + func tmdbDetails(id: Int, mediaKind: TMDBMediaKind, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue { + let endpoint = mediaKind == .tv ? "tv" : "movie" + var components = URLComponents(string: "https://api.themoviedb.org/3/\(endpoint)/\(id)")! + components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "append_to_response", value: "credits,images,external_ids")] + return try await tmdbJSONRequest(components.url!, proxyHost: proxyHost, proxyPort: proxyPort) + } + + func tmdbEpisodeDetails(seriesID: Int, season: Int, episode: Int, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue { + var components = URLComponents(string: "https://api.themoviedb.org/3/tv/\(seriesID)/season/\(season)/episode/\(episode)")! + components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "append_to_response", value: "credits,images")] + return try await tmdbJSONRequest(components.url!, proxyHost: proxyHost, proxyPort: proxyPort) + } + + func tmdbImage(path: String, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> Data { + guard let url = URL(string: "https://image.tmdb.org/t/p/original\(path)") else { throw GuangyaAPIError.invalidResponse } + return try await tmdbDataRequest(url, proxyHost: proxyHost, proxyPort: proxyPort) + } + + private func tmdbJSONRequest(_ url: URL, proxyHost: String, proxyPort: String) async throws -> JSONValue { + let data = try await tmdbDataRequest(url, proxyHost: proxyHost, proxyPort: proxyPort) + return try JSONDecoder().decode(JSONValue.self, from: data) + } + + private func tmdbDataRequest(_ url: URL, proxyHost: String, proxyPort: String) async throws -> Data { + var request = URLRequest(url: url); request.httpMethod = "GET"; request.timeoutInterval = 45; request.setValue("application/json", forHTTPHeaderField: "Accept") + let config = URLSessionConfiguration.ephemeral; config.timeoutIntervalForRequest = 45; config.timeoutIntervalForResource = 120 + let host = proxyHost.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "^https?://", with: "", options: .regularExpression).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if !host.isEmpty, let port = Int(proxyPort), (1...65535).contains(port) { config.connectionProxyDictionary = [kCFNetworkProxiesHTTPEnable as String: true, kCFNetworkProxiesHTTPProxy as String: host, kCFNetworkProxiesHTTPPort as String: port, kCFNetworkProxiesHTTPSEnable as String: true, kCFNetworkProxiesHTTPSProxy as String: host, kCFNetworkProxiesHTTPSPort as String: port] } + let (data, response) = try await URLSession(configuration: config).data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "TMDB 请求失败") } + return data + } + func fileUpload(url: URL, parentID: String? = nil, contentType: String = "application/octet-stream", chunkSize: Int = 5 * 1024 * 1024) async throws -> JSONValue { let values = try Data(contentsOf: url) let name = url.lastPathComponent @@ -265,7 +361,7 @@ final class GuangyaAPI: @unchecked Sendable { } private func apiRequest(_ path: String, method: String = "POST", body: JSONValue? = nil, multipart: [(String, String, String, Data)]? = nil) async throws -> JSONValue { - if let expiry = tokenExpiresAt, Date() >= expiry, refreshTokenValue != nil { _ = try? await refreshToken() } + if let expiry = tokenExpiresAt, Date() >= expiry, refreshTokenValue != nil { try await refreshOrThrowAuthorizationExpired() } var headers = ["traceparent": Self.generateTraceparent()] if !accessToken.isEmpty { headers["authorization"] = "Bearer \(accessToken)" } return try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, multipart: multipart) @@ -275,22 +371,43 @@ final class GuangyaAPI: @unchecked Sendable { try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: "POST", body: body, headers: ["traceparent": Self.generateTraceparent()], refreshOnUnauthorized: false) } - private func request(_ url: URL, method: String, body: JSONValue?, headers: [String: String], multipart: [(String, String, String, Data)]? = nil, refreshOnUnauthorized: Bool = true) async throws -> JSONValue { + private func request(_ url: URL, method: String, body: JSONValue?, headers: [String: String], multipart: [(String, String, String, Data)]? = nil, refreshOnUnauthorized: Bool = true, retryCount: Int = 2) async throws -> JSONValue { var request = URLRequest(url: url) request.httpMethod = method + request.timeoutInterval = multipart == nil ? 45 : 120 headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } if let multipart { let boundary = "Boundary-\(UUID().uuidString)" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = Self.multipartBody(parts: multipart, boundary: boundary) } else if let body { request.httpBody = try JSONEncoder().encode(body) } - let (data, response) = try await session.data(for: request) + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + guard retryCount > 0, Self.isTransientNetworkError(error) else { throw error } + try await Task.sleep(for: .seconds(Double(3 - retryCount) * 0.9 + Double.random(in: 0.15...0.55))) + return try await self.request(url, method: method, body: body, headers: headers, multipart: multipart, refreshOnUnauthorized: refreshOnUnauthorized, retryCount: retryCount - 1) + } guard let http = response as? HTTPURLResponse else { throw GuangyaAPIError.invalidResponse } - if http.statusCode == 401, refreshOnUnauthorized, refreshTokenValue != nil { - _ = try? await refreshToken() + if http.statusCode == 401, refreshOnUnauthorized { + guard refreshTokenValue != nil else { throw GuangyaAPIError.authorizationExpired } + do { + try await refreshOrThrowAuthorizationExpired() + } catch { + // Only an explicitly invalid refresh token ends the local session. + // Transport failures and temporary account-service errors remain visible. + throw error + } return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false) } guard (200..<300).contains(http.statusCode) else { + if retryCount > 0, [408, 429, 500, 502, 503, 504].contains(http.statusCode) { + let retryAfter = Double(http.value(forHTTPHeaderField: "Retry-After") ?? "") ?? Double(3 - retryCount) + try await Task.sleep(for: .seconds(max(0.8, min(retryAfter + Double.random(in: 0.1...0.5), 15)))) + return try await self.request(url, method: method, body: body, headers: headers, multipart: multipart, refreshOnUnauthorized: refreshOnUnauthorized, retryCount: retryCount - 1) + } let message: String if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) { message = decoded.firstStringDeep(["message", "msg", "error_description", "error"]) ?? String(data: data, encoding: .utf8) ?? "未知错误" @@ -302,6 +419,17 @@ final class GuangyaAPI: @unchecked Sendable { if data.isEmpty { return .object([:]) } if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) { if let code = decoded.firstIntDeep(["code"]), code != 0 { + // Some API gateways put an expired-token 401 in the JSON body + // while keeping the HTTP response status at 200. + if code == 401, refreshOnUnauthorized { + guard refreshTokenValue != nil else { throw GuangyaAPIError.authorizationExpired } + do { + try await refreshOrThrowAuthorizationExpired() + } catch { + throw error + } + return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false) + } throw GuangyaAPIError.http(status: code, message: decoded.firstStringDeep(["msg", "message"]) ?? "接口返回错误") } return decoded @@ -354,6 +482,12 @@ final class GuangyaAPI: @unchecked Sendable { return (String(data: data, encoding: .utf8) ?? "", http.allHeaderFields.reduce(into: [:]) { result, item in result[String(describing: item.key).lowercased()] = String(describing: item.value) }) } + private static func isTransientNetworkError(_ error: Error) -> Bool { + let nsError = error as NSError + guard nsError.domain == NSURLErrorDomain else { return false } + return [NSURLErrorTimedOut, NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost, NSURLErrorNotConnectedToInternet, NSURLErrorDNSLookupFailed, NSURLErrorInternationalRoamingOff].contains(nsError.code) + } + private static func multipartBody(parts: [(String, String, String, Data)], boundary: String) -> Data { var body = Data() for (name, filename, contentType, data) in parts { @@ -363,6 +497,17 @@ final class GuangyaAPI: @unchecked Sendable { } private static func xmlValue(_ xml: String, tag: String) -> String? { let pattern = "<\(tag)>(.*?)"; guard let range = xml.range(of: pattern, options: .regularExpression) else { return nil }; let value = String(xml[range]); return value.replacingOccurrences(of: "<\(tag)>", with: "").replacingOccurrences(of: "", with: "") } + private func apiID(_ value: String) -> JSONValue { + // Guangya IDs are decimal strings. Never encode them as Double: 19-digit + // IDs exceed IEEE-754's exact integer range and become invalid parameters. + .string(value) + } + private func apiIDOrEmpty(_ value: String?) -> JSONValue { + guard let value, !value.isEmpty else { return .string("") } + return apiID(value) + } + private func apiIDs(_ values: [String]) -> [JSONValue] { values.map(apiID) } + private func object(_ values: [String: Any]) -> JSONValue { .object(values.reduce(into: [:]) { $0[$1.key] = JSONValue(any: $1.value) }) } private func object(_ values: (String, Any)...) -> JSONValue { object(values.reduce(into: [:]) { $0[$1.0] = JSONValue(any: $1.1) }) } private static func generateDeviceID() -> String { Insecure.MD5.hash(data: Data((0..<16).map { _ in UInt8.random(in: 0...255) })).map { String(format: "%02x", $0) }.joined() } @@ -372,6 +517,4 @@ final class GuangyaAPI: @unchecked Sendable { private static func calculateGCID(data: Data) -> String { let chunkSize = data.count <= 0x8000000 ? 262144 : data.count <= 0x10000000 ? 524288 : data.count <= 0x20000000 ? 1048576 : 2097152; var hashes = Data(); var offset = 0; while offset < data.count { let end = min(offset + chunkSize, data.count); hashes.append(contentsOf: Insecure.SHA1.hash(data: data.subdata(in: offset.. String? { + for key in keys { + if let value = self[key]?.stringValue, !value.isEmpty { return value } + if let int = self[key]?.int64Value { return String(int) } + } + return nil + } + // API responses are not consistent about whether payload fields are nested in `data`. func firstStringDeep(_ keys: [String]) -> String? { if let value = firstString(keys) { return value } @@ -76,6 +86,17 @@ enum JSONValue: Codable, Sendable, Equatable { return nil } + func firstArrayDeep(_ keys: [String]) -> [JSONValue]? { + for key in keys { if let value = self[key]?.arrayValue { return value } } + guard let object = objectValue else { return nil } + let preferredKeys = ["data", "result", "user", "profile", "payload"] + for key in preferredKeys { if let value = object[key]?.firstArrayDeep(keys) { return value } } + for (key, value) in object where !preferredKeys.contains(key) { + if let found = value.firstArrayDeep(keys) { return found } + } + return nil + } + func firstInt(_ keys: [String]) -> Int? { for key in keys where self[key]?.intValue != nil { return self[key]?.intValue } return nil @@ -121,6 +142,8 @@ struct CloudFile: Identifiable, Hashable, Sendable { let isDirectory: Bool var size: Int64? var gcid: String? + var subDirectoryCount: Int? + var subFileCount: Int? var modifiedAt: String let fileType: Int @@ -150,8 +173,9 @@ struct CloudFile: Identifiable, Hashable, Sendable { init?(json: JSONValue) { guard let object = json.objectValue, - let name = json.firstString(["name", "fileName", "resName"]) else { return nil } - id = json.firstString(["fileId", "id", "resId"]) ?? UUID().uuidString + let name = json.firstString(["name", "fileName", "resName"]), + let stableID = json.firstID(["fileId", "resId", "id"]), !stableID.isEmpty else { return nil } + id = stableID self.name = name let resourceType = json.firstInt(["resType"]) let type = json.firstInt(["fileType", "type"]) ?? 0 @@ -165,6 +189,11 @@ struct CloudFile: Identifiable, Hashable, Sendable { } size = json.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]) gcid = json.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]) + subDirectoryCount = json.firstIntDeep(["subDirCount"]) + subFileCount = json.firstIntDeep(["subFileCount"]) + // In get_file_list an absent directory size denotes a verified empty folder. + // Keep it as zero rather than “unknown”, so it sorts and renders correctly. + if isDirectory, size == nil { size = 0 } modifiedAt = json.firstString(["updateTime", "updatedAt", "modifyTime", "createTime"]) ?? json.firstInt64Deep(["utime", "ctime"]).map(formatFileDate) ?? "" fileType = type } @@ -182,6 +211,10 @@ struct TMDBMatch: Identifiable, Sendable { let releaseDate: String let overview: String + init(id: Int, title: String, mediaType: String, releaseDate: String, overview: String) { + self.id = id; self.title = title; self.mediaType = mediaType; self.releaseDate = releaseDate; self.overview = overview + } + init?(json: JSONValue) { guard let id = json.firstIntDeep(["id"]), let title = json.firstStringDeep(["title", "name"]) else { return nil } self.id = id @@ -201,11 +234,23 @@ struct UserProfile: Sendable { let name: String let phone: String let avatarURL: URL? + let memberLevel: String + let capacity: Int64? + let usedCapacity: Int64? init(json: JSONValue) { name = json.firstStringDeep(["nickname", "name", "username", "phone_number"]) ?? "光鸭用户" phone = json.firstStringDeep(["phone", "phoneNumber", "phone_number", "username"]) ?? "" avatarURL = json.firstStringDeep(["avatar", "avatarUrl", "avatar_url"]).flatMap(URL.init(string:)) + memberLevel = json.firstStringDeep(["vipName", "memberName", "memberLevelName", "levelName", "vipLevel"]) ?? "普通会员" + capacity = json.firstInt64Deep(["capacity", "totalCapacity", "totalSpace", "spaceTotal"]) + usedCapacity = json.firstInt64Deep(["usedCapacity", "usedSpace", "usedSize", "spaceUsed"]) + } + + var capacityText: String { + let f = ByteCountFormatter(); f.countStyle = .file + guard let capacity else { return "空间信息暂不可用" } + return "\(usedCapacity.map { f.string(fromByteCount: $0) } ?? "0 bytes") / \(f.string(fromByteCount: capacity))" } } @@ -216,9 +261,12 @@ struct AuthTokens: Sendable { } enum WorkspaceSection: String, CaseIterable, Identifiable { - case files = "我的文件" + case files = "全部" + case recentViewed = "最近查看" + case recentRestored = "最近转存" case photos = "图片" case videos = "视频" + case audio = "音频" case documents = "文档" case cloud = "云下载" case shares = "我的分享" @@ -228,8 +276,11 @@ enum WorkspaceSection: String, CaseIterable, Identifiable { var icon: String { switch self { case .files: return "folder" + case .recentViewed: return "clock.arrow.circlepath" + case .recentRestored: return "arrow.uturn.backward.circle" case .photos: return "photo" case .videos: return "play.rectangle" + case .audio: return "music.note" case .documents: return "doc.text" case .cloud: return "arrow.down.circle" case .shares: return "square.and.arrow.up" @@ -237,3 +288,213 @@ enum WorkspaceSection: String, CaseIterable, Identifiable { } } } +import Foundation + +enum ScanKind: String, CaseIterable, Identifiable, Sendable { + case emptyFolders + case duplicates + case similarFolders + + var id: String { rawValue } + var title: String { + switch self { + case .emptyFolders: return "空文件夹" + case .duplicates: return "重复文件" + case .similarFolders: return "相似文件夹" + } + } + var icon: String { + switch self { + case .emptyFolders: return "folder.badge.minus" + case .duplicates: return "square.on.square" + case .similarFolders: return "rectangle.3.group" + } + } +} + +enum ScanScope: String, CaseIterable, Identifiable, Sendable { + case currentFolder + case recursiveCurrentFolder + case customFolder + case wholeDrive + + var id: String { rawValue } + var title: String { + switch self { + case .currentFolder: return "当前目录" + case .recursiveCurrentFolder: return "当前目录及子目录" + case .customFolder: return "手动选择文件夹" + case .wholeDrive: return "整个云盘" + } + } +} + +struct ScanRequest: Sendable, Equatable { + let kind: ScanKind + let scope: ScanScope + let rootID: String? + let rootName: String + let excludeFilesSmallerThan: Int64? +} + +struct SimilarFolderGroup: Identifiable, Sendable { + let id: String + let folders: [CloudFile] +} + +enum ScanItem: Identifiable, Sendable { + case emptyFolder(CloudFile) + case duplicate(DuplicateGroup) + case similar(SimilarFolderGroup) + + var id: String { + switch self { + case .emptyFolder(let file): return "empty-\(file.id)" + case .duplicate(let group): return "duplicate-\(group.id)" + case .similar(let group): return "similar-\(group.id)" + } + } +} + +struct ScanResult: Identifiable, Sendable { + let id = UUID() + let request: ScanRequest + let items: [ScanItem] + let foldersScanned: Int + let filesScanned: Int + let skippedFolders: Int + + var emptyFolders: [CloudFile] { items.compactMap { if case .emptyFolder(let file) = $0 { return file }; return nil } } +} + +struct ScanProgress: Sendable { + var phase = "等待扫描" + var foldersVisited = 0 + var filesVisited = 0 +} + +enum FileSort: String, CaseIterable, Identifiable, Sendable { + case name, size, modifiedAt, createdAt, type + var id: String { rawValue } + var title: String { ["name": "名称", "size": "大小", "modifiedAt": "修改时间", "createdAt": "创建时间", "type": "类型"][rawValue]! } + // Guangya get_file_list orderBy mapping, confirmed from the web client. + var apiOrderBy: Int { switch self { + case .name: return 0 + case .size: return 1 + case .createdAt: return 2 + case .modifiedAt: return 3 + case .type: return 4 + } } + var supportsServerSorting: Bool { true } +} + +enum SortDirection: Int, Sendable { case ascending = 0, descending = 1 } +import Foundation + +enum TMDBMediaKind: String, CaseIterable, Identifiable, Codable, Sendable { + case automatic, movie, tv + var id: String { rawValue } + var title: String { ["automatic": "自动识别", "movie": "电影", "tv": "剧集"][rawValue]! } +} + +enum TMDBOperation: String, CaseIterable, Identifiable, Codable, Sendable { + case preview, organize + var id: String { rawValue } + var title: String { self == .preview ? "仅生成预览" : "整理并写入 NFO" } +} + +struct TMDBWorkflowConfig: Codable, Sendable { + var mediaKind: TMDBMediaKind = .automatic + var operation: TMDBOperation = .preview + var sourceRecursive = true + var destinationAtRoot = false + var folderTemplate = "{title} ({year})" + var fileTemplate = "{title} ({year})" + var minimumSizeMB = 50 + var allowedExtensions = "mkv, mp4, m4v, avi, mov, ts, webm" + var createNFO = true + var downloadPoster = true + var downloadBackdrop = true + var downloadActorImages = true + var cleanEmptyFolders = false + + static func load() -> TMDBWorkflowConfig { + guard let data = UserDefaults.standard.data(forKey: "guangya.tmdbWorkflowConfig"), let config = try? JSONDecoder().decode(TMDBWorkflowConfig.self, from: data) else { return TMDBWorkflowConfig() } + return config + } + func save() { if let data = try? JSONEncoder().encode(self) { UserDefaults.standard.set(data, forKey: "guangya.tmdbWorkflowConfig") } } +} + +enum TMDBJobState: String, Sendable { case pending, matched, skipped, failed, completed } + +struct TMDBCandidate: Identifiable, Sendable { + let id: Int + let title: String + let originalTitle: String + let mediaType: TMDBMediaKind + let releaseDate: String + let overview: String + let posterPath: String? + + init?(json: JSONValue, forcedKind: TMDBMediaKind? = nil) { + guard let id = json.firstInt(["id"]), let title = json.firstString(["title", "name"]) else { return nil } + let rawType = forcedKind?.rawValue ?? json.firstString(["media_type"]) + guard rawType == "movie" || rawType == "tv" else { return nil } + self.id = id; self.title = title; originalTitle = json.firstString(["original_title", "original_name"]) ?? title + mediaType = rawType == "tv" ? .tv : .movie + releaseDate = json.firstString(["release_date", "first_air_date"]) ?? "" + overview = json.firstString(["overview"]) ?? ""; posterPath = json.firstString(["poster_path"]) + } + + var match: TMDBMatch { TMDBMatch(id: id, title: title, mediaType: mediaType.rawValue, releaseDate: releaseDate, overview: overview) } +} + +struct ParsedMediaName: Sendable { + let title: String + let year: Int? + let season: Int? + let episode: Int? + let isEpisode: Bool + let resolution: String? + let source: String? + let videoCodec: String? + let audio: String? + let isDiscStructure: Bool +} + +struct TMDBJob: Identifiable, Sendable { + let id: String + let file: CloudFile + let parsed: ParsedMediaName + var relatedSubtitles: [CloudFile] = [] + var match: TMDBMatch? + var candidates: [TMDBCandidate] = [] + var selectedCandidateID: Int? + var state: TMDBJobState + var note: String + var isApproved: Bool = false + + var selectedCandidate: TMDBCandidate? { candidates.first { $0.id == selectedCandidateID } } +} +import Foundation + +/// Small async semaphore used to keep speculative row-detail loading from +/// starving interactive API calls such as navigation, delete, and refresh. +actor DetailRequestGate { + private let limit: Int + private var active = 0 + private var waiters: [CheckedContinuation] = [] + + init(limit: Int) { self.limit = limit } + + func acquire() async { + if active < limit { active += 1; return } + await withCheckedContinuation { waiters.append($0) } + active += 1 + } + + func release() { + active = max(0, active - 1) + if !waiters.isEmpty { waiters.removeFirst().resume() } + } +}