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:
hehihoho3@gmail.com 2026-06-25 10:41:23 +09:00
parent ff9609c9d1
commit 77e77894c9
6 changed files with 176 additions and 0 deletions

View File

@ -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)로 보내 영상 싱크 세그먼트를 추출·저장한다. "

View File

@ -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);

View File

@ -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);
}
}
}

View File

@ -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:

View File

@ -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 = '저장 중...';

View File

@ -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);
}
}