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 처리
|
.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)
|
@PostMapping(value = "/{id}/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@Operation(summary = "업로드 영상 전사(Whisper)",
|
@Operation(summary = "업로드 영상 전사(Whisper)",
|
||||||
description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. "
|
description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. "
|
||||||
|
|||||||
@ -25,6 +25,7 @@ public class ChannelVideoCurationService {
|
|||||||
private final ChannelVideoScriptRepository channelVideoScriptRepository;
|
private final ChannelVideoScriptRepository channelVideoScriptRepository;
|
||||||
private final ChannelService channelService;
|
private final ChannelService channelService;
|
||||||
private final VideoDownloadService videoDownloadService;
|
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_STATUS = Set.of("NEW", "REVIEWING", "TARGET", "DONE", "EXCLUDED");
|
||||||
private static final Set<String> ALLOWED_SORT = Set.of("viewsPerHour", "viewsPerSubRatio", "viewCount", "publishedAt", "durationSec");
|
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);
|
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=원본). */
|
/** 저장된 세그먼트로 SRT 문자열을 생성한다. speed 는 배속(1.0=원본). */
|
||||||
public String buildSrt(Long videoId, double speed) {
|
public String buildSrt(Long videoId, double speed) {
|
||||||
ChannelVideo v = find(videoId);
|
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:
|
download:
|
||||||
dir: ${DOWNLOAD_DIR:downloads} # 다운로드 캐시 폴더(작업 디렉토리 기준 상대경로 가능)
|
dir: ${DOWNLOAD_DIR:downloads} # 다운로드 캐시 폴더(작업 디렉토리 기준 상대경로 가능)
|
||||||
|
|
||||||
|
# 자가호스팅 LibreTranslate(원본 스크립트 번역 → 재가공 초안). POST /translate {q,source,target}.
|
||||||
|
translate:
|
||||||
|
base-url: ${TRANSLATE_BASE_URL:https://h-etc2.tolag.shop}
|
||||||
|
|
||||||
hlab:
|
hlab:
|
||||||
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
|
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
|
||||||
scheduler:
|
scheduler:
|
||||||
|
|||||||
@ -152,6 +152,9 @@
|
|||||||
<button class="btn btn-secondary px-3 py-2 flex items-center gap-1" id="copyBtn" onclick="copyToEditor()">
|
<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> 에디터로 복사
|
<i data-lucide="copy" style="width:15px;"></i> 에디터로 복사
|
||||||
</button>
|
</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 삭제(자막은 유지)">
|
<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> 받은 원본 삭제
|
<i data-lucide="trash-2" style="width:15px;"></i> 받은 원본 삭제
|
||||||
</button>
|
</button>
|
||||||
@ -624,6 +627,21 @@
|
|||||||
ed.value = ed.value ? (ed.value + '\n\n' + t) : t;
|
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(){
|
async function saveRework(){
|
||||||
const btn = document.getElementById('saveBtn');
|
const btn = document.getElementById('saveBtn');
|
||||||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '저장 중...';
|
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