e8c3e4644e
CI / lint-and-build (push) Has been cancelled
- Swift 6 + SwiftUI + SwiftData 直接调用阿里云 VOD API - 自实现 POP 签名,无需 Node 后端 - 视频列表、搜索、批量下载到用户选择目录 - 下载文件自动使用视频标题重命名 - 浏览器下载与下载工具(aria2/wget)两种方式 - Web 版标记为废弃,README/AGENTS/MEMORY 更新
233 lines
7.1 KiB
Swift
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 {}
|