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