psyche_subtitle_toolkit/translation/
deepl.rs1use serde::{Deserialize, Serialize};
2
3use crate::error::{Result, SubtitleToolkitError};
4
5use super::{TranslationRequest, Translator};
6
7#[derive(Debug, Clone)]
24pub struct DeepLTranslator {
25 client: reqwest::Client,
26 base_url: String,
27 api_key: String,
28}
29
30impl DeepLTranslator {
31 pub fn new(api_key: impl Into<String>) -> Result<Self> {
33 Self::with_base_url("https://api-free.deepl.com", api_key)
34 }
35
36 pub fn with_base_url(
41 base_url: impl Into<String>,
42 api_key: impl Into<String>,
43 ) -> Result<Self> {
44 let client = reqwest::Client::builder()
45 .timeout(std::time::Duration::from_secs(120))
46 .build()
47 .map_err(SubtitleToolkitError::Http)?;
48 Ok(Self {
49 client,
50 base_url: base_url.into().trim_end_matches('/').to_string(),
51 api_key: api_key.into(),
52 })
53 }
54}
55
56#[async_trait::async_trait]
57impl Translator for DeepLTranslator {
58 async fn translate(&self, request: TranslationRequest<'_>) -> Result<String> {
59 let response = self
60 .client
61 .post(format!("{}/v2/translate", self.base_url))
62 .header(
63 "Authorization",
64 format!("DeepL-Auth-Key {}", self.api_key),
65 )
66 .json(&DeepLTranslateRequest {
67 text: request.source_text.lines().collect(),
68 target_lang: &request.target_language.to_uppercase(),
69 split_sentences: "0",
70 preserve_formatting: true,
71 })
72 .send()
73 .await?;
74
75 if !response.status().is_success() {
76 return Err(SubtitleToolkitError::Translation {
77 provider: "deepl",
78 message: response
79 .text()
80 .await
81 .unwrap_or_else(|_| "request failed".into()),
82 });
83 }
84
85 let body = response.json::<DeepLTranslateResponse>().await?;
86 if body.translations.is_empty() {
87 return Err(SubtitleToolkitError::Translation {
88 provider: "deepl",
89 message: "response contained no translations".into(),
90 });
91 }
92 let translated = body
93 .translations
94 .into_iter()
95 .map(|t| t.text)
96 .collect::<Vec<_>>()
97 .join("\n");
98
99 Ok(translated)
100 }
101}
102
103#[derive(Debug, Serialize)]
104struct DeepLTranslateRequest<'a> {
105 text: Vec<&'a str>,
106 target_lang: &'a str,
107 split_sentences: &'static str,
108 preserve_formatting: bool,
109}
110
111#[derive(Debug, Deserialize)]
112struct DeepLTranslateResponse {
113 translations: Vec<DeepLTranslation>,
114}
115
116#[derive(Debug, Deserialize)]
117struct DeepLTranslation {
118 text: String,
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use wiremock::matchers::{header, method, path};
125 use wiremock::{Mock, MockServer, ResponseTemplate};
126
127 #[tokio::test]
128 async fn translates_numbered_text() {
129 let server = MockServer::start().await;
130
131 Mock::given(method("POST"))
132 .and(path("/v2/translate"))
133 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
134 "translations": [{ "text": "<1> Olá" }, { "text": "<2> mundo" }]
135 })))
136 .mount(&server)
137 .await;
138
139 let translator = DeepLTranslator::with_base_url(server.uri(), "test-key").unwrap();
140 let result = translator
141 .translate(TranslationRequest {
142 source_text: "<1> hello\n<2> world",
143 target_language: "pt-BR",
144 })
145 .await
146 .unwrap();
147
148 assert_eq!(result, "<1> Olá\n<2> mundo");
149 }
150
151 #[tokio::test]
152 async fn sends_deepl_auth_header() {
153 let server = MockServer::start().await;
154
155 Mock::given(method("POST"))
156 .and(header("Authorization", "DeepL-Auth-Key my-secret-key"))
157 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
158 "translations": [{ "text": "<1> ok" }]
159 })))
160 .mount(&server)
161 .await;
162
163 let translator = DeepLTranslator::with_base_url(server.uri(), "my-secret-key").unwrap();
164 translator
165 .translate(TranslationRequest {
166 source_text: "<1> test",
167 target_language: "de",
168 })
169 .await
170 .unwrap();
171 }
172
173 #[tokio::test]
174 async fn uppercases_target_language() {
175 let server = MockServer::start().await;
176
177 Mock::given(method("POST"))
178 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
179 "translations": [{ "text": "<1> ok" }]
180 })))
181 .mount(&server)
182 .await;
183
184 let translator = DeepLTranslator::with_base_url(server.uri(), "test-key").unwrap();
185 translator
187 .translate(TranslationRequest {
188 source_text: "<1> test",
189 target_language: "pt-BR",
190 })
191 .await
192 .unwrap();
193 }
194
195 #[tokio::test]
196 async fn error_on_non_200() {
197 let server = MockServer::start().await;
198
199 Mock::given(method("POST"))
200 .respond_with(
201 ResponseTemplate::new(403).set_body_string("Quota exceeded"),
202 )
203 .mount(&server)
204 .await;
205
206 let translator = DeepLTranslator::with_base_url(server.uri(), "bad-key").unwrap();
207 let err = translator
208 .translate(TranslationRequest {
209 source_text: "<1> hello",
210 target_language: "de",
211 })
212 .await
213 .unwrap_err();
214
215 assert!(err.to_string().contains("deepl"));
216 assert!(err.to_string().contains("Quota exceeded"));
217 }
218
219 #[tokio::test]
220 async fn error_on_empty_translations() {
221 let server = MockServer::start().await;
222
223 Mock::given(method("POST"))
224 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
225 "translations": []
226 })))
227 .mount(&server)
228 .await;
229
230 let translator = DeepLTranslator::with_base_url(server.uri(), "test-key").unwrap();
231 let err = translator
232 .translate(TranslationRequest {
233 source_text: "<1> hello",
234 target_language: "de",
235 })
236 .await
237 .unwrap_err();
238
239 assert!(err.to_string().contains("no translations"));
240 }
241
242 #[tokio::test]
243 async fn sends_each_line_as_separate_array_element() {
244 let server = MockServer::start().await;
245
246 Mock::given(method("POST"))
247 .and(path("/v2/translate"))
248 .and(wiremock::matchers::body_string_contains(r#""<1> hello""#))
249 .and(wiremock::matchers::body_string_contains(r#""<2> world""#))
250 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
251 "translations": [{ "text": "<1> Olá" }, { "text": "<2> mundo" }]
252 })))
253 .expect(1)
254 .mount(&server)
255 .await;
256
257 let translator = DeepLTranslator::with_base_url(server.uri(), "test-key").unwrap();
258 translator
259 .translate(TranslationRequest {
260 source_text: "<1> hello\n<2> world",
261 target_language: "pt-BR",
262 })
263 .await
264 .unwrap();
265 }
266
267 #[tokio::test]
268 async fn joins_translated_lines_back() {
269 let server = MockServer::start().await;
270
271 Mock::given(method("POST"))
272 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
273 "translations": [
274 { "text": "<1> Zeile eins" },
275 { "text": "<2> Zeile zwei" },
276 { "text": "<3> Zeile drei" }
277 ]
278 })))
279 .mount(&server)
280 .await;
281
282 let translator = DeepLTranslator::with_base_url(server.uri(), "test-key").unwrap();
283 let result = translator
284 .translate(TranslationRequest {
285 source_text: "<1> Line one\n<2> Line two\n<3> Line three",
286 target_language: "de",
287 })
288 .await
289 .unwrap();
290
291 assert_eq!(result, "<1> Zeile eins\n<2> Zeile zwei\n<3> Zeile drei");
292 }
293}