Files
mediaplayer/ios/OralTrainer/Services/SentenceBoundaryAPI.swift

153 lines
5.3 KiB
Swift

import Foundation
enum SentenceBoundaryAPIError: LocalizedError {
case invalidHash
case hashMismatch
var errorDescription: String? {
switch self {
case .invalidHash:
return "video_hash 必须是 64 位 SHA-256 十六进制字符串"
case .hashMismatch:
return "服务端返回的句边界哈希与请求不一致"
}
}
}
enum SentenceBoundaryAPI {
static func fetch(videoHash: String) async throws -> SentenceBoundaryDocument {
let hash = videoHash.lowercased()
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
throw SentenceBoundaryAPIError.invalidHash
}
var headers: [String: String] = [:]
if !AppConfig.authToken.isEmpty {
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
}
let result = try await HTTPClient.getJSON(
SentenceBoundaryDocument.self,
baseURL: AppConfig.serverBaseURL,
path: "api/v1/videos/\(hash)/sentence-boundaries",
timeout: AppConfig.readTimeout,
additionalHeaders: headers
)
guard result.value.videoHash.lowercased() == hash else {
throw SentenceBoundaryAPIError.hashMismatch
}
return result.value
}
}
struct SentenceExplanationResponse: Decodable {
let explanation: String
private enum CodingKeys: String, CodingKey {
case explanation
}
}
struct SubtitleTranslationItem: Decodable {
let index: Int
let zhText: String
private enum CodingKeys: String, CodingKey {
case index
case zhText = "zh_text"
}
}
struct SubtitleTranslationResponse: Decodable {
let videoHash: String
let translations: [SubtitleTranslationItem]
private enum CodingKeys: String, CodingKey {
case translations
case videoHash = "video_hash"
}
}
extension SentenceBoundaryAPI {
static func explain(
_ sentence: SentenceBoundary,
courseTitle: String
) async throws -> String {
var body: [String: Any] = [
"sentence_text": sentence.text ?? "",
"course_title": courseTitle,
"sentence_index": sentence.index,
]
if let language = sentence.language {
body["language"] = language
}
var headers: [String: String] = ["Content-Type": "application/json"]
if !AppConfig.authToken.isEmpty {
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
}
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: "api/v1/sentences/explain",
method: "POST",
timeout: AppConfig.readTimeout,
headers: headers,
body: try JSONSerialization.data(withJSONObject: body)
)
guard (200...299).contains(result.statusCode) else {
let detail = String(data: result.body, encoding: .utf8) ?? ""
throw HTTPClientError.badStatus(result.statusCode, detail)
}
return try JSONDecoder()
.decode(SentenceExplanationResponse.self, from: result.body)
.explanation
}
static func fetchStoredSubtitles(videoHash: String) async throws -> [Int: String] {
try await requestSubtitles(videoHash: videoHash, method: "GET", timeout: AppConfig.readTimeout)
}
static func translateSubtitles(videoHash: String) async throws -> [Int: String] {
try await requestSubtitles(videoHash: videoHash, method: "POST", timeout: AppConfig.translationTimeout)
}
static func loadSubtitles(videoHash: String, expectedIndexes: [Int]) async throws -> [Int: String] {
if let stored = try? await fetchStoredSubtitles(videoHash: videoHash) {
let expected = Set(expectedIndexes)
if expected.isEmpty || expected.isSubset(of: Set(stored.keys)) {
return stored
}
}
return try await translateSubtitles(videoHash: videoHash)
}
private static func requestSubtitles(
videoHash: String,
method: String,
timeout: TimeInterval
) async throws -> [Int: String] {
let hash = videoHash.lowercased()
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
throw SentenceBoundaryAPIError.invalidHash
}
var headers: [String: String] = ["Accept": "application/json"]
if !AppConfig.authToken.isEmpty {
headers["Authorization"] = "Bearer \(AppConfig.authToken)"
}
let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL,
path: "api/v1/videos/\(hash)/subtitle-translation",
method: method,
timeout: timeout,
headers: headers
)
guard (200...299).contains(result.statusCode) else {
let detail = String(data: result.body, encoding: .utf8) ?? ""
throw HTTPClientError.badStatus(result.statusCode, detail)
}
let payload = try JSONDecoder().decode(SubtitleTranslationResponse.self, from: result.body)
var mapped: [Int: String] = [:]
for item in payload.translations where !item.zhText.isEmpty {
mapped[item.index] = item.zhText
}
return mapped
}
}