223 lines
7.7 KiB
Swift
223 lines
7.7 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]
|
|
let complete: Bool?
|
|
let status: String?
|
|
let error: String?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case translations
|
|
case videoHash = "video_hash"
|
|
case complete
|
|
case status
|
|
case error
|
|
}
|
|
|
|
var mapped: [Int: String] {
|
|
var values: [Int: String] = [:]
|
|
for item in translations where !item.zhText.isEmpty {
|
|
values[item.index] = item.zhText
|
|
}
|
|
return values
|
|
}
|
|
}
|
|
|
|
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 -> SubtitleTranslationResponse {
|
|
try await requestSubtitles(videoHash: videoHash, method: "GET", timeout: AppConfig.readTimeout)
|
|
}
|
|
|
|
static func translateSubtitles(videoHash: String) async throws -> SubtitleTranslationResponse {
|
|
try await requestSubtitles(videoHash: videoHash, method: "POST", timeout: AppConfig.translationTimeout)
|
|
}
|
|
|
|
static func loadSubtitles(
|
|
videoHash: String,
|
|
expectedIndexes: [Int],
|
|
onProgress: (([Int: String]) -> Void)? = nil
|
|
) async throws -> [Int: String] {
|
|
let expected = Set(expectedIndexes)
|
|
let deadline = Date().addingTimeInterval(AppConfig.translationDeadline)
|
|
var kickJob = true
|
|
var lastError: Error?
|
|
var lastCount = -1
|
|
var stalledRounds = 0
|
|
while Date() < deadline {
|
|
try Task.checkCancellation()
|
|
if let stored = try? await fetchStoredSubtitles(videoHash: videoHash) {
|
|
let mapped = stored.mapped
|
|
onProgress?(mapped)
|
|
if isComplete(mapped, expected: expected, payload: stored) {
|
|
return mapped
|
|
}
|
|
if mapped.count > lastCount {
|
|
lastCount = mapped.count
|
|
stalledRounds = 0
|
|
} else {
|
|
stalledRounds += 1
|
|
}
|
|
if stored.status != "running" && stalledRounds >= 2 {
|
|
kickJob = true
|
|
}
|
|
} else {
|
|
kickJob = true
|
|
}
|
|
if kickJob {
|
|
do {
|
|
let posted = try await translateSubtitles(videoHash: videoHash)
|
|
let mapped = posted.mapped
|
|
onProgress?(mapped)
|
|
if isComplete(mapped, expected: expected, payload: posted) {
|
|
return mapped
|
|
}
|
|
if posted.status == "error" {
|
|
lastError = HTTPClientError.badStatus(503, posted.error ?? "Subtitle translation failed")
|
|
}
|
|
} catch {
|
|
lastError = error
|
|
if !isTimeout(error) {
|
|
stalledRounds += 1
|
|
}
|
|
}
|
|
kickJob = false
|
|
}
|
|
try await Task.sleep(nanoseconds: AppConfig.translationPollIntervalNs)
|
|
}
|
|
throw lastError ?? HTTPClientError.badStatus(408, "Subtitle translation timed out")
|
|
}
|
|
|
|
private static func isComplete(
|
|
_ mapped: [Int: String],
|
|
expected: Set<Int>,
|
|
payload: SubtitleTranslationResponse
|
|
) -> Bool {
|
|
if !expected.isEmpty {
|
|
return expected.isSubset(of: Set(mapped.keys))
|
|
}
|
|
return payload.complete == true
|
|
}
|
|
|
|
private static func isTimeout(_ error: Error) -> Bool {
|
|
let nsError = error as NSError
|
|
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut
|
|
}
|
|
|
|
private static func requestSubtitles(
|
|
videoHash: String,
|
|
method: String,
|
|
timeout: TimeInterval
|
|
) async throws -> SubtitleTranslationResponse {
|
|
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)
|
|
}
|
|
return try JSONDecoder().decode(SubtitleTranslationResponse.self, from: result.body)
|
|
}
|
|
}
|