feat(rework): 원본 스크립트 번역 → 재작성(내 버전) 버튼 추가
자가호스팅 LibreTranslate(h-etc2.tolag.shop) 연동. 재가공 화면 '번역→재작성'
버튼으로 원본 전사 스크립트를 한국어로 번역해 재작성 칸 초안으로 채운다.
소스 언어는 전사 언어로 자동(없으면 auto), 타깃 기본 ko.
- TranslateService 신규: POST /translate {q,source,target} → translatedText
(요청 조립/응답 파싱 순수 메서드 + 단위테스트)
- POST /{id}/translate 엔드포인트, CurationService.translateScript
- rework.html: '번역→재작성' 버튼 + translateToEditor()
- 설정 translate.base-url(기본값 有)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ff9609c9d1
commit
77e77894c9
@ -169,6 +169,15 @@ public class ChannelVideoCurationController {
|
||||
.body(resource); // Resource 반환 → Spring 이 Range 요청을 자동으로 206 처리
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/translate")
|
||||
@Operation(summary = "원본 스크립트 번역 → 재가공 초안",
|
||||
description = "원본 전사 스크립트를 LibreTranslate로 번역해 반환한다(프론트가 '재작성' 칸에 채움). "
|
||||
+ "쿼리: target(기본 ko). 소스 언어는 전사 언어로 자동. 응답: {translatedText, source, target}.")
|
||||
public ApiResponse<Map<String, Object>> translate(@PathVariable Long id,
|
||||
@RequestParam(value = "target", required = false) String target) {
|
||||
return ApiResponse.ok(curationService.translateScript(id, target));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(summary = "업로드 영상 전사(Whisper)",
|
||||
description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. "
|
||||
|
||||
@ -25,6 +25,7 @@ public class ChannelVideoCurationService {
|
||||
private final ChannelVideoScriptRepository channelVideoScriptRepository;
|
||||
private final ChannelService channelService;
|
||||
private final VideoDownloadService videoDownloadService;
|
||||
private final com.hlab.yanalyst.service.TranslateService translateService;
|
||||
|
||||
private static final Set<String> ALLOWED_STATUS = Set.of("NEW", "REVIEWING", "TARGET", "DONE", "EXCLUDED");
|
||||
private static final Set<String> ALLOWED_SORT = Set.of("viewsPerHour", "viewsPerSubRatio", "viewCount", "publishedAt", "durationSec");
|
||||
@ -176,6 +177,29 @@ public class ChannelVideoCurationService {
|
||||
return channelService.renderTrimmedFromCached(v.getId(), file, pad, minGap, speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 원본 스크립트(전사 평문)를 번역해 재가공(내 버전) 초안용 텍스트로 반환한다.
|
||||
* source 는 전사 언어(자동감지가 있으면 그 값), target 기본 ko. 결과 저장은 사용자가 검토 후 직접.
|
||||
*/
|
||||
public Map<String, Object> translateScript(Long videoId, String target) {
|
||||
ChannelVideo v = find(videoId);
|
||||
var scriptOpt = channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(v.getVideoId());
|
||||
String text = scriptOpt.map(ChannelVideoScript::getTranscript).orElse(null);
|
||||
if (text == null || text.isBlank()) {
|
||||
throw new IllegalArgumentException("번역할 원본 스크립트가 없습니다. 먼저 전사(원본 다운로드/업로드)하세요.");
|
||||
}
|
||||
String source = scriptOpt.map(ChannelVideoScript::getLanguage).orElse(null);
|
||||
String tgt = (target == null || target.isBlank()) ? "ko" : target.trim();
|
||||
|
||||
String translated = translateService.translate(text, source, tgt);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("translatedText", translated);
|
||||
result.put("source", source);
|
||||
result.put("target", tgt);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 저장된 세그먼트로 SRT 문자열을 생성한다. speed 는 배속(1.0=원본). */
|
||||
public String buildSrt(Long videoId, double speed) {
|
||||
ChannelVideo v = find(videoId);
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
package com.hlab.yanalyst.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 자가호스팅 LibreTranslate 연동. 원본 스크립트를 번역해 재가공(내 버전) 초안으로 쓴다.
|
||||
* API: {@code POST /translate {q, source, target}} → {@code {"translatedText": "..."}}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TranslateService {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${translate.base-url:https://h-etc2.tolag.shop}")
|
||||
private String baseUrl;
|
||||
|
||||
/** text 를 source→target 으로 번역해 평문을 반환. source 가 비면 자동감지("auto"). */
|
||||
public String translate(String text, String source, String target) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String apiUrl = baseUrl + "/translate";
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
HttpEntity<Map<String, String>> request =
|
||||
new HttpEntity<>(buildRequestBody(text, source, target), headers);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, request, String.class);
|
||||
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||
throw new RuntimeException("Translate API 실패: " + response.getStatusCode());
|
||||
}
|
||||
return extractTranslatedText(objectMapper, response.getBody());
|
||||
} catch (Exception e) {
|
||||
log.error("번역 실패 (source={}, target={}, len={})", source, target,
|
||||
text.length(), e);
|
||||
throw new RuntimeException("번역 실패: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** LibreTranslate /translate 요청 바디 조립(순수). source 빈값/null → "auto". */
|
||||
static Map<String, String> buildRequestBody(String text, String source, String target) {
|
||||
Map<String, String> body = new LinkedHashMap<>();
|
||||
body.put("q", text);
|
||||
body.put("source", (source == null || source.isBlank()) ? "auto" : source.trim());
|
||||
body.put("target", target);
|
||||
body.put("format", "text");
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 응답 JSON 에서 translatedText 추출(순수). 필드 없으면 빈 문자열, 파싱 실패 시 예외. */
|
||||
static String extractTranslatedText(ObjectMapper objectMapper, String json) {
|
||||
try {
|
||||
return objectMapper.readTree(json).path("translatedText").asText("");
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("번역 응답 파싱 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -63,6 +63,10 @@ ytdlp:
|
||||
download:
|
||||
dir: ${DOWNLOAD_DIR:downloads} # 다운로드 캐시 폴더(작업 디렉토리 기준 상대경로 가능)
|
||||
|
||||
# 자가호스팅 LibreTranslate(원본 스크립트 번역 → 재가공 초안). POST /translate {q,source,target}.
|
||||
translate:
|
||||
base-url: ${TRANSLATE_BASE_URL:https://h-etc2.tolag.shop}
|
||||
|
||||
hlab:
|
||||
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
|
||||
scheduler:
|
||||
|
||||
@ -152,6 +152,9 @@
|
||||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="copyBtn" onclick="copyToEditor()">
|
||||
<i data-lucide="copy" style="width:15px;"></i> 에디터로 복사
|
||||
</button>
|
||||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="translateBtn" onclick="translateToEditor()" title="원본 스크립트를 한국어로 번역해 ‘재작성(내 버전)’ 칸에 넣기">
|
||||
<i data-lucide="languages" style="width:15px;"></i> 번역→재작성
|
||||
</button>
|
||||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="deleteDlBtn" onclick="deleteDownload()" style="display:none; color:#ef4444;" title="서버에 받아둔 원본 mp4 삭제(자막은 유지)">
|
||||
<i data-lucide="trash-2" style="width:15px;"></i> 받은 원본 삭제
|
||||
</button>
|
||||
@ -624,6 +627,21 @@
|
||||
ed.value = ed.value ? (ed.value + '\n\n' + t) : t;
|
||||
}
|
||||
|
||||
// 원본 스크립트를 한국어로 번역해 '재작성(내 버전)' 칸에 넣는다.
|
||||
async function translateToEditor(){
|
||||
const btn = document.getElementById('translateBtn');
|
||||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '번역 중…';
|
||||
try {
|
||||
const r = await api(API + '/' + VIDEO_ID + '/translate?target=ko', { method:'POST' });
|
||||
const t = (r && r.translatedText) || '';
|
||||
if(!t){ alert('번역 결과가 비었습니다. (원본 스크립트가 있는지 확인하세요)'); return; }
|
||||
const ed = document.getElementById('reworkText');
|
||||
ed.value = ed.value ? (ed.value + '\n\n' + t) : t;
|
||||
ed.focus();
|
||||
} catch(e){ alert('번역 실패: ' + e.message); }
|
||||
finally { btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons(); }
|
||||
}
|
||||
|
||||
async function saveRework(){
|
||||
const btn = document.getElementById('saveBtn');
|
||||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '저장 중...';
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
package com.hlab.yanalyst.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/** LibreTranslate 요청 조립/응답 파싱의 순수 로직 테스트(HTTP 없음). */
|
||||
class TranslateServiceTest {
|
||||
|
||||
private final ObjectMapper om = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void buildRequestBody_소스가_있으면_그대로_없으면_auto() {
|
||||
Map<String, String> withSrc = TranslateService.buildRequestBody("Hello", "en", "ko");
|
||||
assertThat(withSrc).containsEntry("q", "Hello")
|
||||
.containsEntry("source", "en")
|
||||
.containsEntry("target", "ko")
|
||||
.containsEntry("format", "text");
|
||||
|
||||
Map<String, String> autoSrc = TranslateService.buildRequestBody("Hello", " ", "ko");
|
||||
assertThat(autoSrc).containsEntry("source", "auto");
|
||||
|
||||
Map<String, String> nullSrc = TranslateService.buildRequestBody("Hello", null, "ko");
|
||||
assertThat(nullSrc).containsEntry("source", "auto");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractTranslatedText_정상응답에서_번역문을_뽑는다() {
|
||||
String json = "{\"translatedText\":\"안녕하세요\"}";
|
||||
assertThat(TranslateService.extractTranslatedText(om, json)).isEqualTo("안녕하세요");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractTranslatedText_필드없으면_빈문자열() {
|
||||
assertThat(TranslateService.extractTranslatedText(om, "{}")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractTranslatedText_깨진json이면_예외() {
|
||||
assertThatThrownBy(() -> TranslateService.extractTranslatedText(om, "not-json"))
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user