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, 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) } }