feat: 扩展云盘分类接口与TMDB数据能力

This commit is contained in:
2026-07-16 09:50:16 +08:00
parent 7b5f35a81e
commit db5c46414c
2 changed files with 440 additions and 36 deletions
+176 -33
View File
@@ -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)>(.*?)</\(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: "</\(tag)>", 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..<end))); offset = end }; return Insecure.SHA1.hash(data: hashes).map { String(format: "%02X", $0) }.joined() }
}
private extension JSONValue {
var boolValue: Bool? { if case .bool(let value) = self { return value }; return nil }
}
+264 -3
View File
@@ -37,6 +37,7 @@ enum JSONValue: Codable, Sendable, Equatable {
init(any value: Any) {
switch value {
case let value as JSONValue: self = value
case let value as String: self = .string(value)
case let value as Int: self = .number(Double(value))
case let value as Int64: self = .number(Double(value))
@@ -51,6 +52,7 @@ enum JSONValue: Codable, Sendable, Equatable {
var objectValue: [String: JSONValue]? { if case .object(let value) = self { return value }; return nil }
var arrayValue: [JSONValue]? { if case .array(let value) = self { return value }; return nil }
var stringValue: String? { if case .string(let value) = self { return value }; return nil }
var boolValue: Bool? { if case .bool(let value) = self { return value }; return nil }
var doubleValue: Double? { if case .number(let value) = self { return value }; return nil }
var intValue: Int? { doubleValue.map(Int.init) }
var int64Value: Int64? { doubleValue.map(Int64.init) }
@@ -62,6 +64,14 @@ enum JSONValue: Codable, Sendable, Equatable {
return nil
}
func firstID(_ keys: [String]) -> 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<Void, Never>] = []
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() }
}
}