h-lab/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoCurationController.java
hehihoho3@gmail.com 986dcffd55 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>
2026-06-25 09:10:02 +09:00

222 lines
13 KiB
Java

package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/channel-videos")
@RequiredArgsConstructor
@Tag(name = "Channel Video Curation API", description = "수집한 채널 영상의 분류/필터/관리")
public class ChannelVideoCurationController {
private final ChannelVideoCurationService curationService;
@GetMapping
@Operation(summary = "큐레이션 조회",
description = "categoryId/status/source(CHANNEL|SEARCH)/shortsOnly/bookmarkedOnly 로 필터, "
+ "sortBy(viewsPerHour|viewsPerSubRatio|viewCount|publishedAt|durationSec) 로 내림차순 정렬.")
public ApiResponse<List<ChannelVideo>> search(
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String source,
@RequestParam(defaultValue = "false") boolean shortsOnly,
@RequestParam(defaultValue = "false") boolean bookmarkedOnly,
@RequestParam(required = false) String sortBy) {
return ApiResponse.ok(curationService.search(categoryId, status, source, shortsOnly, bookmarkedOnly, sortBy));
}
@GetMapping("/outperformers")
@Operation(summary = "떡상 후보 자동 발굴",
description = "구독자 대비 조회수 비율이 높은 Shorts 를 자동 선별. limit(기본20), minRatio(기본2.0).")
public ApiResponse<List<ChannelVideo>> outperformers(
@RequestParam(required = false) Integer limit,
@RequestParam(required = false) java.math.BigDecimal minRatio) {
return ApiResponse.ok(curationService.findOutperformers(limit, minRatio));
}
@GetMapping("/discover")
@Operation(summary = "발굴(Discovery) 조회",
description = "필터: periodDays(최근 N일), minRatio(배율 하한), shortsOnly, source(CHANNEL|SEARCH), "
+ "unprocessedOnly(NEW/REVIEWING만). 정렬 sortBy(기본 viewsPerSubRatio↓), limit(기본100). "
+ "EXCLUDED 는 항상 제외.")
public ApiResponse<List<ChannelVideo>> discover(
@RequestParam(required = false) Integer periodDays,
@RequestParam(required = false) java.math.BigDecimal minRatio,
@RequestParam(defaultValue = "false") boolean shortsOnly,
@RequestParam(required = false) String source,
@RequestParam(defaultValue = "false") boolean unprocessedOnly,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) Integer limit) {
return ApiResponse.ok(curationService.discover(periodDays, minRatio, shortsOnly, source, unprocessedOnly, sortBy, limit));
}
@PostMapping("/backfill")
@Operation(summary = "기존 수집 영상 지표 백필",
description = "새 컬럼 추가 이전에 수집된 영상의 파생 지표/큐레이션 기본값을 재계산(외부 API 호출 없음, 재실행 안전).")
public ApiResponse<Map<String, Object>> backfill() {
return ApiResponse.ok(curationService.backfillMetrics());
}
@GetMapping("/stats")
@Operation(summary = "수집/파이프라인 통계",
description = "총 수집 수, 상태별/출처별 분포 — 대시보드·칸반 보드용 요약.")
public ApiResponse<Map<String, Object>> stats() {
return ApiResponse.ok(curationService.pipelineStats());
}
@PostMapping("/{id}/category")
@Operation(summary = "카테고리 지정/해제", description = "body: {\"categoryId\": 1} — null 또는 미포함 시 분류 해제")
public ApiResponse<ChannelVideo> assignCategory(@PathVariable Long id, @RequestBody(required = false) Map<String, Object> body) {
Long categoryId = null;
if (body != null && body.get("categoryId") != null) {
categoryId = ((Number) body.get("categoryId")).longValue();
}
return ApiResponse.ok(curationService.assignCategory(id, categoryId));
}
@PostMapping("/{id}/bookmark")
@Operation(summary = "북마크 설정", description = "body: {\"bookmarked\": true}")
public ApiResponse<ChannelVideo> setBookmark(@PathVariable Long id, @RequestBody Map<String, Object> body) {
boolean bookmarked = Boolean.TRUE.equals(body.get("bookmarked"));
return ApiResponse.ok(curationService.setBookmark(id, bookmarked));
}
@PostMapping("/{id}/status")
@Operation(summary = "관심 상태 변경", description = "body: {\"status\": \"TARGET\"} — NEW|REVIEWING|TARGET|EXCLUDED")
public ApiResponse<ChannelVideo> changeStatus(@PathVariable Long id, @RequestBody Map<String, String> body) {
return ApiResponse.ok(curationService.changeStatus(id, body.get("status")));
}
@PostMapping("/{id}/memo")
@Operation(summary = "메모 저장", description = "body: {\"memo\": \"...\"}")
public ApiResponse<ChannelVideo> updateMemo(@PathVariable Long id, @RequestBody Map<String, String> body) {
return ApiResponse.ok(curationService.updateMemo(id, body.get("memo")));
}
@DeleteMapping("/{id}")
@Operation(summary = "수집함에서 영상 제거", description = "연결된 스크립트도 함께 삭제된다.")
public ApiResponse<Void> delete(@PathVariable Long id) {
curationService.delete(id);
return ApiResponse.ok(null);
}
// ===== 재가공(재작성) =====
@GetMapping("/{id}")
@Operation(summary = "수집 영상 단건 조회", description = "재가공 작업공간용 상세 정보.")
public ApiResponse<ChannelVideo> getOne(@PathVariable Long id) {
return ApiResponse.ok(curationService.getOne(id));
}
@GetMapping("/{id}/script")
@Operation(summary = "원본 스크립트 조회",
description = "추출된 transcript(평문) + 영상 싱크 segments([{start,end,text}]). 없으면 transcript=\"\", segments=[].")
public ApiResponse<Map<String, Object>> getScript(@PathVariable Long id) {
return ApiResponse.ok(curationService.getScriptData(id));
}
@PostMapping("/{id}/extract-script")
@Operation(summary = "원본 스크립트 추출(URL 자막)", description = "외부 transcript 서비스로 YouTube 자막을 추출해 저장한다.")
public ApiResponse<Map<String, Object>> extractScript(@PathVariable Long id) {
String t = curationService.extractTranscript(id);
return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t));
}
@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,
@RequestParam(value = "language", required = false) String 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)
@Operation(summary = "업로드 영상 전사(Whisper)",
description = "업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다. "
+ "multipart 필드: file, language(선택: ko|en|zh|ja… 비우면 자동감지). "
+ "응답: {hasScript, language, duration, transcript, segments}.")
public ApiResponse<Map<String, Object>> transcribe(@PathVariable Long id,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "language", required = false) String language) {
return ApiResponse.ok(curationService.transcribeFromFile(id, file, language));
}
@GetMapping("/{id}/trim-plan")
@Operation(summary = "말 없는 구간 제거 미리보기",
description = "저장된 세그먼트로 keep/remove 구간과 재매핑된 세그먼트, 예상 길이를 계산(영상 파일 불필요). "
+ "pad(말 앞뒤 여백초, 기본0.15), minGap(이하 간격은 안 끊음, 기본0.3).")
public ApiResponse<Map<String, Object>> trimPlan(@PathVariable Long id,
@RequestParam(defaultValue = "0.15") double pad,
@RequestParam(defaultValue = "0.3") double minGap) {
return ApiResponse.ok(curationService.trimPlan(id, pad, minGap));
}
@PostMapping(value = "/{id}/render", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "말 없는 구간 제거(+배속) 영상 렌더",
description = "영상에서 말 없는 구간을 잘라낸(+배속) 영상을 ffmpeg로 만들어 mp4 다운로드. "
+ "multipart: file(선택), pad, minGap, speed. file 을 보내면 그 업로드본을, 없으면 '원본 다운로드'로 받은 "
+ "서버 캐시 파일을 사용한다(캐시 없으면 400). keep 구간은 저장 세그먼트로 서버가 계산(미리보기와 일치).")
public ResponseEntity<byte[]> render(@PathVariable Long id,
@RequestParam(value = "file", required = false) MultipartFile file,
@RequestParam(defaultValue = "0.15") double pad,
@RequestParam(defaultValue = "0.3") double minGap,
@RequestParam(defaultValue = "1.0") double speed) {
byte[] mp4 = (file != null && !file.isEmpty())
? curationService.renderTrimmed(id, file, pad, minGap, speed)
: curationService.renderTrimmedFromCached(id, pad, minGap, speed);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(new MediaType("video", "mp4"));
headers.setContentDisposition(
org.springframework.http.ContentDisposition.attachment()
.filename("trimmed_" + id + ".mp4", StandardCharsets.UTF_8).build());
return ResponseEntity.ok().headers(headers).body(mp4);
}
@GetMapping("/{id}/script.srt")
@Operation(summary = "SRT 자막 내보내기",
description = "저장된 세그먼트로 SRT 파일을 생성해 다운로드한다. speed(기본 1.0)로 배속 보정. CapCut import 용.")
public ResponseEntity<byte[]> exportSrt(@PathVariable Long id,
@RequestParam(defaultValue = "1.0") double speed) {
String srt = curationService.buildSrt(id, speed);
byte[] bytes = srt.getBytes(StandardCharsets.UTF_8);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(new MediaType("application", "x-subrip", StandardCharsets.UTF_8));
headers.setContentDisposition(
org.springframework.http.ContentDisposition.attachment()
.filename("script_" + id + ".srt", StandardCharsets.UTF_8).build());
return ResponseEntity.ok().headers(headers).body(bytes);
}
@PostMapping("/{id}/rework")
@Operation(summary = "재작성 초안 저장", description = "body: {\"reworkText\": \"...\"} — 저장 시 상태가 TARGET 으로 승격된다.")
public ApiResponse<ChannelVideo> saveRework(@PathVariable Long id, @RequestBody Map<String, String> body) {
return ApiResponse.ok(curationService.saveRework(id, body.get("reworkText")));
}
}