Files
Aliyun-VOD-Media-Library-Ma…/apps/macos/VODManager/Sources/VODManager/Services/VODClient.swift
T
William e8c3e4644e
CI / lint-and-build (push) Has been cancelled
feat: 新增 macOS 原生 SwiftUI 客户端
- Swift 6 + SwiftUI + SwiftData 直接调用阿里云 VOD API

- 自实现 POP 签名,无需 Node 后端

- 视频列表、搜索、批量下载到用户选择目录

- 下载文件自动使用视频标题重命名

- 浏览器下载与下载工具(aria2/wget)两种方式

- Web 版标记为废弃,README/AGENTS/MEMORY 更新
2026-06-30 08:38:43 +06:00

233 lines
7.1 KiB
Swift

import Foundation
enum VODClientError: Error {
case noActiveAccount
case invalidResponse
case apiError(String)
case networkError(Error)
}
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",
"SignatureNonce": UUID().uuidString,
"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()
var components = URLComponents()
components.scheme = "https"
components.host = host
components.path = "/"
components.queryItems = params.sorted(by: { $0.key < $1.key }).map {
URLQueryItem(name: $0.key, value: $0.value)
}
guard let url = components.url 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()
return try decoder.decode(T.self, from: data)
}
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 {}