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:
parent
f4ac1be171
commit
986dcffd55
@ -143,6 +143,19 @@ public class ChannelVideoCurationController {
|
||||
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)
|
||||
@Operation(summary = "업로드 영상 전사(Whisper)",
|
||||
description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. "
|
||||
|
||||
@ -143,6 +143,22 @@ public class ChannelVideoCurationService {
|
||||
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) {
|
||||
ChannelVideo v = find(videoId);
|
||||
@ -194,12 +210,13 @@ public class ChannelVideoCurationService {
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 수집함에서 영상 제거(연결된 스크립트도 함께 삭제). */
|
||||
/** 수집함에서 영상 제거(연결된 스크립트 + 다운로드 캐시도 함께 삭제). */
|
||||
@Transactional
|
||||
public void delete(Long videoId) {
|
||||
ChannelVideo video = find(videoId);
|
||||
channelVideoScriptRepository.deleteAll(
|
||||
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
||||
videoDownloadService.deleteCache(video.getId()); // 고아 mp4 방지
|
||||
channelVideoRepository.delete(video);
|
||||
}
|
||||
|
||||
|
||||
@ -122,6 +122,21 @@ public class VideoDownloadService {
|
||||
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 명령 인자 리스트를 조립한다(프로세스 실행 없음 — 단위테스트 대상).
|
||||
* videoId 는 {@code [A-Za-z0-9_-]{11}} 형식만 허용한다.
|
||||
|
||||
@ -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="deleteDlBtn" onclick="deleteDownload()" style="display:none; color:#ef4444;" title="서버에 받아둔 원본 mp4 삭제(자막은 유지)">
|
||||
<i data-lucide="trash-2" style="width:15px;"></i> 받은 원본 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -459,7 +462,7 @@
|
||||
const lang = document.getElementById('langSel').value;
|
||||
const url = API + '/' + VIDEO_ID + '/download' + (lang ? ('?language=' + lang) : '');
|
||||
const s = await api(url, { method:'POST' });
|
||||
HAS_SERVER_FILE = true; // 렌더는 서버 캐시본 사용
|
||||
updateCacheUI(true, s.sizeBytes || 0); // 서버 캐시 보유 → 렌더 캐시 사용 + 삭제버튼 노출
|
||||
UPLOADED_FILE = null;
|
||||
renderSegments(s.segments);
|
||||
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){
|
||||
const file = ev.target.files && ev.target.files[0];
|
||||
if(!file) return;
|
||||
UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관
|
||||
HAS_SERVER_FILE = false; // 업로드본 우선
|
||||
UPLOADED_FILE = file; // 렌더(자르기) 재전송용으로 보관(업로드본이 서버 캐시보다 우선)
|
||||
|
||||
// 1) 업로드한 파일을 로컬에서 즉시 재생(<video>) + YouTube iframe 숨김
|
||||
const v = document.getElementById('localVideo');
|
||||
@ -539,6 +571,12 @@
|
||||
renderSegments(s.segments);
|
||||
} 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');
|
||||
lv.addEventListener('timeupdate', ()=> highlightAt(lv.currentTime));
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
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.util.List;
|
||||
|
||||
@ -53,4 +56,17 @@ class VideoDownloadServiceTest {
|
||||
"yt-dlp", "", 1080, null, Path.of("x.mp4")))
|
||||
.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(); // 이미 없음
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user