feat(rework): 받은 원본 삭제 + 영상 삭제 시 캐시 자동정리

1) 재가공 화면에 '받은 원본 삭제' 버튼 추가(캐시 보유 시에만 노출).
   GET/DELETE /{id}/download 로 캐시 상태 조회·삭제(자막/세그먼트는 유지).
   진입 시 캐시 상태를 조회해 삭제버튼 노출 + 렌더 캐시 사용 여부를 갱신.
2) 수집함 영상 삭제 시 downloads/{id}.mp4 도 함께 삭제(고아 파일 방지).

- VideoDownloadService.deleteCache(id) + 단위테스트(@TempDir)
- CurationService: downloadStatus/deleteDownloadCache, delete() 캐시 정리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-25 09:10:02 +09:00
parent f4ac1be171
commit 986dcffd55
5 changed files with 103 additions and 4 deletions

View File

@ -143,6 +143,19 @@ public class ChannelVideoCurationController {
return ApiResponse.ok(curationService.downloadAndTranscribe(id, language)); return ApiResponse.ok(curationService.downloadAndTranscribe(id, language));
} }
@GetMapping("/{id}/download")
@Operation(summary = "받은 원본 상태", description = "원본 다운로드 캐시 보유 여부와 용량을 반환한다. 응답: {cached, sizeBytes}.")
public ApiResponse<Map<String, Object>> downloadStatus(@PathVariable Long id) {
return ApiResponse.ok(curationService.downloadStatus(id));
}
@DeleteMapping("/{id}/download")
@Operation(summary = "받은 원본 삭제", description = "원본 다운로드로 받아둔 캐시 mp4를 삭제한다(자막/세그먼트는 유지). 응답: {deleted}.")
public ApiResponse<Map<String, Object>> deleteDownload(@PathVariable Long id) {
boolean deleted = curationService.deleteDownloadCache(id);
return ApiResponse.ok(Map.of("deleted", deleted));
}
@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)로 보내 영상 싱크 세그먼트를 추출·저장한다. "

View File

@ -143,6 +143,22 @@ public class ChannelVideoCurationService {
return result; return result;
} }
/** 다운로드 캐시 상태(보유 여부 + 용량). 재가공 진입 시 '받은 원본 삭제' 버튼 표시/렌더 캐시 사용 판단에 쓴다. */
public Map<String, Object> downloadStatus(Long videoId) {
ChannelVideo v = find(videoId);
java.util.Optional<java.io.File> cached = videoDownloadService.cachedFile(v.getId());
Map<String, Object> result = new LinkedHashMap<>();
result.put("cached", cached.isPresent());
result.put("sizeBytes", cached.map(java.io.File::length).orElse(0L));
return result;
}
/** 재가공 화면 '받은 원본 삭제' — 다운로드 캐시 mp4 삭제(자막/세그먼트는 유지). */
public boolean deleteDownloadCache(Long videoId) {
ChannelVideo v = find(videoId);
return videoDownloadService.deleteCache(v.getId());
}
/** 다운로드 캐시 파일로 말 없는 구간 제거(+배속) 렌더. 캐시 없으면 안내 예외. */ /** 다운로드 캐시 파일로 말 없는 구간 제거(+배속) 렌더. 캐시 없으면 안내 예외. */
public byte[] renderTrimmedFromCached(Long videoId, double pad, double minGap, double speed) { public byte[] renderTrimmedFromCached(Long videoId, double pad, double minGap, double speed) {
ChannelVideo v = find(videoId); ChannelVideo v = find(videoId);
@ -194,12 +210,13 @@ public class ChannelVideoCurationService {
return v; return v;
} }
/** 수집함에서 영상 제거(연결된 스크립트도 함께 삭제). */ /** 수집함에서 영상 제거(연결된 스크립트 + 다운로드 캐시도 함께 삭제). */
@Transactional @Transactional
public void delete(Long videoId) { public void delete(Long videoId) {
ChannelVideo video = find(videoId); ChannelVideo video = find(videoId);
channelVideoScriptRepository.deleteAll( channelVideoScriptRepository.deleteAll(
channelVideoScriptRepository.findAllByVideoId(video.getVideoId())); channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
videoDownloadService.deleteCache(video.getId()); // 고아 mp4 방지
channelVideoRepository.delete(video); channelVideoRepository.delete(video);
} }

View File

@ -122,6 +122,21 @@ public class VideoDownloadService {
return cachedFile(channelVideoId).isPresent(); return cachedFile(channelVideoId).isPresent();
} }
/** 캐시된 다운로드 파일 삭제. 존재해서 지웠으면 true, 원래 없었으면 false. */
public boolean deleteCache(Long channelVideoId) {
File file = Path.of(downloadDir).resolve(channelVideoId + ".mp4").toFile();
if (!file.exists()) {
return false;
}
boolean ok = file.delete();
if (ok) {
log.info("다운로드 캐시 삭제: {}", file);
} else {
log.warn("다운로드 캐시 삭제 실패(파일 잠김?): {}", file);
}
return ok;
}
/** /**
* yt-dlp 명령 인자 리스트를 조립한다(프로세스 실행 없음 단위테스트 대상). * yt-dlp 명령 인자 리스트를 조립한다(프로세스 실행 없음 단위테스트 대상).
* videoId {@code [A-Za-z0-9_-]{11}} 형식만 허용한다. * videoId {@code [A-Za-z0-9_-]{11}} 형식만 허용한다.

View File

@ -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="deleteDlBtn" onclick="deleteDownload()" style="display:none; color:#ef4444;" title="서버에 받아둔 원본 mp4 삭제(자막은 유지)">
<i data-lucide="trash-2" style="width:15px;"></i> 받은 원본 삭제
</button>
</div> </div>
</div> </div>
@ -459,7 +462,7 @@
const lang = document.getElementById('langSel').value; const lang = document.getElementById('langSel').value;
const url = API + '/' + VIDEO_ID + '/download' + (lang ? ('?language=' + lang) : ''); const url = API + '/' + VIDEO_ID + '/download' + (lang ? ('?language=' + lang) : '');
const s = await api(url, { method:'POST' }); const s = await api(url, { method:'POST' });
HAS_SERVER_FILE = true; // 렌더는 서버 캐시본 사용 updateCacheUI(true, s.sizeBytes || 0); // 서버 캐시 보유 → 렌더 캐시 사용 + 삭제버튼 노출
UPLOADED_FILE = null; UPLOADED_FILE = null;
renderSegments(s.segments); renderSegments(s.segments);
document.getElementById('transcript').value = s.transcript || ''; document.getElementById('transcript').value = s.transcript || '';
@ -477,11 +480,40 @@
} }
} }
// 서버 캐시 보유 상태 → '받은 원본 삭제' 버튼 표시 + 렌더 캐시 사용 여부(HAS_SERVER_FILE) 갱신
function updateCacheUI(cached, sizeBytes){
HAS_SERVER_FILE = !!cached;
const btn = document.getElementById('deleteDlBtn');
if(!btn) return;
btn.style.display = cached ? '' : 'none';
if(cached && sizeBytes){
btn.title = '서버에 받아둔 원본(' + (sizeBytes/1048576).toFixed(1) + 'MB) 삭제(자막은 유지)';
}
}
// 받아둔 원본 mp4 삭제(자막/세그먼트는 유지)
async function deleteDownload(){
if(!confirm('서버에 받아둔 원본 영상(mp4)을 삭제할까요?\n자막·세그먼트는 그대로 유지됩니다.')) return;
const btn = document.getElementById('deleteDlBtn');
const status = document.getElementById('transcribeStatus');
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '삭제 중…';
try {
const r = await api(API + '/' + VIDEO_ID + '/download', { method:'DELETE' });
updateCacheUI(false, 0);
status.style.display = 'block'; status.style.color = '#9ca3af';
status.textContent = r && r.deleted ? '받은 원본을 삭제했습니다. (다시 받으려면 ‘원본 다운로드’)' : '삭제할 원본이 없습니다.';
} catch(e){
status.style.display = 'block'; status.style.color = '#f87171';
status.textContent = '원본 삭제 실패: ' + e.message;
} finally {
btn.disabled = false; btn.innerHTML = orig; if(window.lucide) lucide.createIcons();
}
}
async function onVideoSelected(ev){ async function onVideoSelected(ev){
const file = ev.target.files && ev.target.files[0]; const file = ev.target.files && ev.target.files[0];
if(!file) return; if(!file) return;
UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관 UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관(업로드본이 서버 캐시보다 우선)
HAS_SERVER_FILE = false; // 업로드본 우선
// 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김 // 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김
const v = document.getElementById('localVideo'); const v = document.getElementById('localVideo');
@ -539,6 +571,12 @@
renderSegments(s.segments); renderSegments(s.segments);
} catch(e){ /* ignore */ } } catch(e){ /* ignore */ }
// 받아둔 원본(서버 캐시) 상태 → 삭제버튼/렌더 캐시 사용 갱신(이전 세션에 받아둔 것도 반영)
try {
const d = await api(API + '/' + VIDEO_ID + '/download');
updateCacheUI(!!d.cached, d.sizeBytes || 0);
} catch(e){ /* ignore */ }
// 업로드 영상 재생에 맞춰 현재 세그먼트 하이라이트 // 업로드 영상 재생에 맞춰 현재 세그먼트 하이라이트
const lv = document.getElementById('localVideo'); const lv = document.getElementById('localVideo');
lv.addEventListener('timeupdate', ()=> highlightAt(lv.currentTime)); lv.addEventListener('timeupdate', ()=> highlightAt(lv.currentTime));

View File

@ -1,7 +1,10 @@
package com.hlab.yanalyst.domain.channel; package com.hlab.yanalyst.domain.channel;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
@ -53,4 +56,17 @@ class VideoDownloadServiceTest {
"yt-dlp", "", 1080, null, Path.of("x.mp4"))) "yt-dlp", "", 1080, null, Path.of("x.mp4")))
.isInstanceOf(IllegalArgumentException.class); .isInstanceOf(IllegalArgumentException.class);
} }
@Test
void deleteCache_파일이_있으면_지우고true_없으면false(@TempDir Path tmp) throws Exception {
VideoDownloadService svc = new VideoDownloadService(null); // deleteCache repo 미사용
ReflectionTestUtils.setField(svc, "downloadDir", tmp.toString());
Path file = tmp.resolve("77.mp4");
Files.writeString(file, "dummy");
assertThat(svc.deleteCache(77L)).isTrue(); // 있으니 삭제
assertThat(Files.exists(file)).isFalse();
assertThat(svc.deleteCache(77L)).isFalse(); // 이미 없음
}
} }