- Swift 6 + SwiftUI + SwiftData 直接调用阿里云 VOD API - 自实现 POP 签名,无需 Node 后端 - 视频列表、搜索、批量下载到用户选择目录 - 下载文件自动使用视频标题重命名 - 浏览器下载与下载工具(aria2/wget)两种方式 - Web 版标记为废弃,README/AGENTS/MEMORY 更新
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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 {}
|
||||
Reference in New Issue
Block a user