Files
Aliyun-VOD-Media-Library-Ma…/apps/macos/VODManager/VODManager/Services/VODClient.swift
T
William 7ec5cee7b0 fix: 封面图 HTTP → HTTPS 绕过 ATS 限制;每页页面标题独立;无账号提示本地化修复
- 封面图加载失败:阿里云 VOD CoverURL 返回 HTTP,ATS 默认禁止,替换为 HTTPS
- 签名编码优化:percentEncodeURL 不编码逗号,URL 中 Fields 参数保持原样
- 每个页面独立 navigationTitle(视频库/下载任务/账号配置/操作日志)
- 无账号时 ContentUnavailableView 显示本地化提示
- 每页条数选择器宽度 80→120,避免文字截断
2026-06-30 16:27:34 +06:00

255 lines
8.0 KiB
Swift

import Foundation
enum VODClientError: Error, LocalizedError {
case noActiveAccount
case invalidResponse
case apiError(String)
case networkError(Error)
var errorDescription: String? {
switch self {
case .noActiveAccount:
return "没有激活的阿里云账号,请先在「账号配置」中添加并切换账号"
case .invalidResponse:
return "无法解析服务器响应"
case .apiError(let message):
return "阿里云 API 错误:\(message)"
case .networkError(let underlying):
return "网络错误:\(underlying.localizedDescription)"
}
}
}
actor VODClient {
static let shared = VODClient()
private let session = URLSession.shared
private var activeAccount: Account?
func setActiveAccount(_ account: Account) {
self.activeAccount = account
}
private func endpoint() -> String {
guard let account = activeAccount else { return "" }
if let endpoint = account.endpoint, !endpoint.isEmpty {
return endpoint
}
return "vod.\(account.region).aliyuncs.com"
}
private func baseParameters() -> [String: String] {
guard let account = activeAccount else { return [:] }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return [
"AccessKeyId": account.accessKeyId,
"Format": "JSON",
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": UUID().uuidString,
"SignatureVersion": "1.0",
"Timestamp": formatter.string(from: Date()),
"Version": "2017-03-21",
]
}
private func requestURL(action: String, parameters: [String: String]) async throws -> URL {
guard let account = activeAccount else {
throw VODClientError.noActiveAccount
}
var params = baseParameters()
params.merge(parameters) { _, new in new }
params["Action"] = action
let signature = try VODSignature.sign(parameters: params, accessKeySecret: account.accessKeySecret)
params["Signature"] = signature
let host = endpoint()
let query = params.sorted(by: { $0.key < $1.key })
.map { "\(VODSignature.percentEncodeURL($0.key))=\(VODSignature.percentEncodeURL($0.value))" }
.joined(separator: "&")
let urlString = "https://\(host)/?\(query)"
guard let url = URL(string: urlString) else {
throw VODClientError.invalidResponse
}
return url
}
func request<T: Decodable>(action: String, parameters: [String: String]) async throws -> T {
let url = try await requestURL(action: action, parameters: parameters)
let (data, response) = try await session.data(from: url)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode >= 400 {
if let errorResponse = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let message = errorResponse["Message"] as? String {
throw VODClientError.apiError(message)
}
throw VODClientError.apiError("HTTP \(httpResponse.statusCode)")
}
let decoder = JSONDecoder()
do {
return try decoder.decode(T.self, from: data)
} catch {
if action == "GetPlayInfo" {
print("[VODClient] GetPlayInfo 解码失败: \(error)")
if let raw = String(data: data, encoding: .utf8) {
print("[VODClient] GetPlayInfo 原始响应: \(raw)")
}
}
throw error
}
}
func searchMedia(
keyword: String? = nil,
cateId: Int? = nil,
pageNo: Int = 1,
pageSize: Int = 10,
sortBy: String = "CreationTime",
sortOrder: String = "Desc"
) async throws -> SearchMediaResponse {
var params: [String: String] = [
"SearchType": "video",
"Fields": "Title,CoverURL,CreationTime,Status,Duration,Size,CateId,CateName,Tags",
"PageNo": "\(pageNo)",
"PageSize": "\(min(pageSize, 100))",
"SortBy": sortBy,
"SortOrder": sortOrder,
]
var matchParts: [String] = []
if let keyword = keyword, !keyword.isEmpty {
matchParts.append("Title='\(escapeMatch(keyword))'")
}
if let cateId = cateId {
matchParts.append("CateId=\(cateId)")
}
if !matchParts.isEmpty {
params["Match"] = matchParts.joined(separator: " and ")
}
return try await request(action: "SearchMedia", parameters: params)
}
func getVideoInfo(videoId: String) async throws -> GetVideoInfoResponse {
return try await request(action: "GetVideoInfo", parameters: ["VideoId": videoId])
}
func getPlayInfo(videoId: String, definition: String? = nil) async throws -> GetPlayInfoResponse {
var params: [String: String] = ["VideoId": videoId]
if let definition = definition {
params["Definition"] = definition
}
return try await request(action: "GetPlayInfo", parameters: params)
}
func deleteVideos(videoIds: [String]) async throws {
let _: EmptyResponse = try await request(action: "DeleteVideo", parameters: ["VideoIds": videoIds.joined(separator: ",")])
}
func updateVideoInfo(
videoId: String,
title: String? = nil,
description: String? = nil,
tags: String? = nil,
cateId: Int? = nil
) async throws {
var params: [String: String] = ["VideoId": videoId]
if let title = title { params["Title"] = title }
if let description = description { params["Description"] = description }
if let tags = tags { params["Tags"] = tags }
if let cateId = cateId { params["CateId"] = "\(cateId)" }
let _: EmptyResponse = try await request(action: "UpdateVideoInfo", parameters: params)
}
func getCategories() async throws -> GetCategoriesResponse {
return try await request(action: "GetCategories", parameters: ["PageSize": "100"])
}
private func escapeMatch(_ value: String) -> String {
return value.replacingOccurrences(of: "'", with: "\\'")
}
}
struct SearchMediaResponse: Decodable {
var Total: Int?
var PageNo: Int?
var PageSize: Int?
var MediaList: [MediaItem]?
var videos: [VideoItem] {
(MediaList ?? []).map { $0.video }
}
}
struct MediaItem: Decodable {
var Video: MediaVideo?
var MediaId: String?
var CreationTime: String?
var video: VideoItem {
let v = Video ?? MediaVideo()
return VideoItem(
videoId: v.VideoId ?? MediaId ?? "",
title: v.Title ?? "",
description: nil,
duration: v.Duration,
size: v.Size,
coverURL: v.CoverURL,
status: v.Status,
cateId: v.CateId,
cateName: v.CateName,
tags: v.Tags,
creationTime: v.CreationTime ?? CreationTime,
modificationTime: v.ModificationTime
)
}
}
struct MediaVideo: Decodable {
var VideoId: String?
var Title: String?
var Duration: Double?
var Size: Int64?
var CoverURL: String?
var Status: String?
var CateId: Int?
var CateName: String?
var Tags: String?
var CreationTime: String?
var ModificationTime: String?
}
struct GetVideoInfoResponse: Decodable {
var Video: MediaVideo
}
struct GetPlayInfoResponse: Decodable {
var PlayInfoList: PlayInfoList?
var playInfos: [PlayInfo] {
PlayInfoList?.PlayInfo ?? []
}
}
struct PlayInfoList: Decodable {
var PlayInfo: [PlayInfo]
}
struct GetCategoriesResponse: Decodable {
var SubCategory: CategoryWrapper?
var categories: [CategoryItem] {
SubCategory?.SubCategory ?? []
}
}
struct CategoryWrapper: Decodable {
var SubCategory: [CategoryItem]
}
struct EmptyResponse: Decodable {}