fix(rework): 원본 다운로드와 전사 분리 — 전사 지연/실패에 안 갇히게

기존엔 '원본 다운로드'가 다운로드+Whisper전사를 한 동기호출로 처리해, 전사 서버
(h-python)가 느리거나 타임아웃나면 화면이 '다운로드 중'에서 안 풀렸다.
다운로드(빠름)와 전사(분리)를 나눠, 다운로드는 즉시 끝나고 영상이 바로 뜨며
전사가 실패해도 다운로드 결과는 유지(화면자막 OCR/재시도 가능).

- POST /{id}/download: 다운로드만(캐시), 응답 {downloaded, sizeBytes}
- POST /{id}/transcribe-cached: 받은 원본 전사(분리)
- downloadOriginal(): 1)다운로드 즉시완료·영상표시 2)전사 별도(실패해도 다운로드 유지)

검증: /download 2.7초 완료. 전사는 분리 단계로 진행/실패 격리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-25 15:19:18 +09:00
parent 6a0a7294bb
commit bba81760a2
3 changed files with 58 additions and 21 deletions

View File

@ -134,13 +134,21 @@ public class ChannelVideoCurationController {
}
@PostMapping("/{id}/download")
@Operation(summary = "원본 다운로드 + 자동 전사",
description = "저장된 videoId 로 yt-dlp 가 원본 영상을 받아 서버에 캐시하고, 그 파일을 바로 Whisper 로 전사한다. "
+ "쿼리: language(선택: ko|en|zh|ja… 비우면 자동감지). 응답: {downloaded, sizeBytes, hasScript, language, duration, transcript, segments}. "
+ "수동 다운로드+업로드 단계를 제거. 이후 렌더도 같은 캐시 파일을 재사용한다.")
public ApiResponse<Map<String, Object>> download(@PathVariable Long id,
@Operation(summary = "원본 다운로드(캐시만)",
description = "저장된 videoId 로 yt-dlp 가 원본 영상을 받아 서버에 캐시한다(빠름, 전사 제외). "
+ "전사는 분리된 /{id}/transcribe-cached 로 진행 → 전사 서버가 느려도 다운로드는 잃지 않는다. "
+ "응답: {downloaded, sizeBytes}. 이후 전사·렌더·OCR 가 이 캐시 파일을 재사용한다.")
public ApiResponse<Map<String, Object>> download(@PathVariable Long id) {
return ApiResponse.ok(curationService.downloadOriginal(id));
}
@PostMapping("/{id}/transcribe-cached")
@Operation(summary = "받은 원본 전사(Whisper)",
description = "'원본 다운로드'로 받아둔 캐시 파일을 Whisper 로 전사해 세그먼트를 저장한다(다운로드와 분리). "
+ "쿼리: language(선택, 비우면 자동감지). 응답: {hasScript, language, duration, transcript, segments}.")
public ApiResponse<Map<String, Object>> transcribeCached(@PathVariable Long id,
@RequestParam(value = "language", required = false) String language) {
return ApiResponse.ok(curationService.downloadAndTranscribe(id, language));
return ApiResponse.ok(curationService.transcribeCached(id, language));
}
@GetMapping("/{id}/download")

View File

@ -124,18 +124,33 @@ public class ChannelVideoCurationService {
}
/**
* 저장된 videoId 원본을 yt-dlp 받아 서버에 캐시하고, 파일을 바로 Whisper 전사한다.
* 수동 다운로드+업로드 단계를 제거한다. language=null 이면 자동감지.
* 저장된 videoId 원본을 yt-dlp 받아 서버에 캐시 한다(빠름). 전사는 분리된 단계로 진행.
* 전사 서버가 느리거나 실패해도 다운로드 결과(영상) 잃지 않는다.
*/
@Transactional
public Map<String, Object> downloadAndTranscribe(Long videoId, String language) {
public Map<String, Object> downloadOriginal(Long videoId) {
ChannelVideo v = find(videoId);
java.io.File file = videoDownloadService.download(v.getId());
ScriptResponseDto dto = channelService.transcribeFromCached(v.getId(), file, language);
Map<String, Object> result = new LinkedHashMap<>();
result.put("downloaded", true);
result.put("sizeBytes", file.length());
return result;
}
/**
* 받아둔 원본 캐시 파일을 Whisper 전사한다(다운로드와 분리된 단계). language=null 이면 자동감지.
* 캐시 없으면 안내 예외.
*/
@Transactional
public Map<String, Object> transcribeCached(Long videoId, String language) {
ChannelVideo v = find(videoId);
java.io.File file = videoDownloadService.cachedFile(v.getId())
.orElseThrow(() -> new IllegalArgumentException(
"받은 원본이 없습니다. 먼저 '원본 다운로드'를 실행하세요."));
ScriptResponseDto dto = channelService.transcribeFromCached(v.getId(), file, language);
Map<String, Object> result = new LinkedHashMap<>();
result.put("hasScript", true);
result.put("language", dto.getLanguage());
result.put("duration", dto.getDuration());

View File

@ -510,22 +510,36 @@
btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '다운로드 중…';
status.style.display = 'block'; status.style.color = '#facc15';
status.textContent = '원본 다운로드 중… (yt-dlp, 영상 길이에 따라 수십 초~수 분)';
// 1단계: 다운로드(빠름). 실패하면 여기서 종료.
try {
const lang = document.getElementById('langSel').value;
const url = API + '/' + VIDEO_ID + '/download' + (lang ? ('?language=' + lang) : '');
const s = await api(url, { method:'POST' });
const d = await api(API + '/' + VIDEO_ID + '/download', { method:'POST' });
UPLOADED_FILE = null; // 다운로드본으로 전환(업로드본보다 우선순위 양보)
updateCacheUI(true, s.sizeBytes || 0); // 캐시 보유 → 삭제버튼 노출 + 왼쪽 플레이어를 받은 원본으로
renderSegments(s.segments);
document.getElementById('transcript').value = s.transcript || '';
updateCacheUI(true, d.sizeBytes || 0); // 캐시 보유 → 삭제버튼 노출 + 왼쪽 플레이어를 받은 원본으로
const mb = d.sizeBytes ? ((d.sizeBytes/1048576).toFixed(1) + 'MB') : '';
status.style.color = '#4ade80';
const mb = s.sizeBytes ? ((s.sizeBytes/1048576).toFixed(1) + 'MB · ') : '';
status.textContent = '다운로드+전사 완료 · ' + mb + (s.segments ? s.segments.length : 0) + '개 세그먼트'
+ (s.language ? (' · ' + s.language) : '')
+ (s.duration ? (' · ' + mmss(s.duration)) : '');
status.textContent = '다운로드 완료 ✓ ' + mb + ' · 전사 중…';
} catch(e){
status.style.color = '#f87171';
status.textContent = '다운로드 실패: ' + e.message;
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
if(window.lucide) lucide.createIcons();
return;
}
// 2단계: 전사(분리). 실패해도 다운로드(영상)는 유지 → 화면자막 OCR/재시도 가능.
try {
const lang = document.getElementById('langSel').value;
const url = API + '/' + VIDEO_ID + '/transcribe-cached' + (lang ? ('?language=' + lang) : '');
const s = await api(url, { method:'POST' });
renderSegments(s.segments);
document.getElementById('transcript').value = s.transcript || '';
status.style.color = '#4ade80';
status.textContent = '다운로드+전사 완료 · ' + (s.segments ? s.segments.length : 0) + '개 세그먼트'
+ (s.language ? (' · ' + s.language) : '')
+ (s.duration ? (' · ' + mmss(s.duration)) : '');
} catch(e){
status.style.color = '#fbbf24';
status.textContent = '다운로드는 완료 ✓ · 전사 실패(서버 혼잡일 수 있음): ' + e.message
+ ' — 잠시 후 다시 전사하거나 ‘화면 자막 OCR을 쓰세요';
} finally {
btn.disabled = false; btn.style.opacity = ''; btn.innerHTML = orig;
if(window.lucide) lucide.createIcons();