feat: 扩展云盘分类接口与TMDB数据能力
This commit is contained in:
+176
-33
@@ -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
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user