From 77e77894c9bb9f1e8e8c7bd7675d4568e4cbc4c2 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Thu, 25 Jun 2026 10:41:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(rework):=20=EC=9B=90=EB=B3=B8=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EB=B2=88=EC=97=AD=20=E2=86=92=20?= =?UTF-8?q?=EC=9E=AC=EC=9E=91=EC=84=B1(=EB=82=B4=20=EB=B2=84=EC=A0=84)=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자가호스팅 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) --- .../ChannelVideoCurationController.java | 9 +++ .../channel/ChannelVideoCurationService.java | 24 ++++++ .../yanalyst/service/TranslateService.java | 74 +++++++++++++++++++ src/main/resources/application.yml | 4 + src/main/resources/templates/rework.html | 18 +++++ .../service/TranslateServiceTest.java | 47 ++++++++++++ 6 files changed, 176 insertions(+) create mode 100644 src/main/java/com/hlab/yanalyst/service/TranslateService.java create mode 100644 src/test/java/com/hlab/yanalyst/service/TranslateServiceTest.java diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java index 9ea3b03..8143e86 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java @@ -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> 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)로 보내 영상 싱크 세그먼트를 추출·저장한다. " diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java index 92094a9..683a244 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationService.java @@ -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 ALLOWED_STATUS = Set.of("NEW", "REVIEWING", "TARGET", "DONE", "EXCLUDED"); private static final Set 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 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 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); diff --git a/src/main/java/com/hlab/yanalyst/service/TranslateService.java b/src/main/java/com/hlab/yanalyst/service/TranslateService.java new file mode 100644 index 0000000..5048580 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/service/TranslateService.java @@ -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> request = + new HttpEntity<>(buildRequestBody(text, source, target), headers); + + ResponseEntity 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 buildRequestBody(String text, String source, String target) { + Map 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); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3ed0b47..798e14a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/main/resources/templates/rework.html b/src/main/resources/templates/rework.html index 066a496..01d9e42 100644 --- a/src/main/resources/templates/rework.html +++ b/src/main/resources/templates/rework.html @@ -152,6 +152,9 @@ + @@ -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 = '저장 중...'; diff --git a/src/test/java/com/hlab/yanalyst/service/TranslateServiceTest.java b/src/test/java/com/hlab/yanalyst/service/TranslateServiceTest.java new file mode 100644 index 0000000..7f1b948 --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/service/TranslateServiceTest.java @@ -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 withSrc = TranslateService.buildRequestBody("Hello", "en", "ko"); + assertThat(withSrc).containsEntry("q", "Hello") + .containsEntry("source", "en") + .containsEntry("target", "ko") + .containsEntry("format", "text"); + + Map autoSrc = TranslateService.buildRequestBody("Hello", " ", "ko"); + assertThat(autoSrc).containsEntry("source", "auto"); + + Map 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); + } +}