diff --git a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java index a790880..0c57f04 100644 --- a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java +++ b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java @@ -6,6 +6,7 @@ import com.microsoft.playwright.*; import com.tasteby.domain.Restaurant; import com.tasteby.security.AuthUtil; import com.tasteby.service.CacheService; +import com.tasteby.service.GeocodingService; import com.tasteby.service.RestaurantService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,12 +32,14 @@ public class RestaurantController { private static final Logger log = LoggerFactory.getLogger(RestaurantController.class); private final RestaurantService restaurantService; + private final GeocodingService geocodingService; private final CacheService cache; private final ObjectMapper objectMapper; private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); - public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) { + public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper) { this.restaurantService = restaurantService; + this.geocodingService = geocodingService; this.cache = cache; this.objectMapper = objectMapper; } @@ -82,11 +85,43 @@ public class RestaurantController { AuthUtil.requireAdmin(); var r = restaurantService.findById(id); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); + + // Re-geocode if name or address changed + String newName = (String) body.get("name"); + String newAddress = (String) body.get("address"); + boolean nameChanged = newName != null && !newName.equals(r.getName()); + boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress()); + if (nameChanged || addressChanged) { + String geoName = newName != null ? newName : r.getName(); + String geoAddr = newAddress != null ? newAddress : r.getAddress(); + var geo = geocodingService.geocodeRestaurant(geoName, geoAddr); + if (geo != null) { + body.put("latitude", geo.get("latitude")); + body.put("longitude", geo.get("longitude")); + body.put("google_place_id", geo.get("google_place_id")); + if (geo.containsKey("formatted_address")) { + body.put("address", geo.get("formatted_address")); + } + if (geo.containsKey("rating")) body.put("rating", geo.get("rating")); + if (geo.containsKey("rating_count")) body.put("rating_count", geo.get("rating_count")); + if (geo.containsKey("phone")) body.put("phone", geo.get("phone")); + if (geo.containsKey("business_status")) body.put("business_status", geo.get("business_status")); + + // formatted_address에서 region 파싱 (예: "대한민국 서울특별시 강남구 ..." → "한국|서울|강남구") + String addr = (String) geo.get("formatted_address"); + if (addr != null) { + body.put("region", GeocodingService.parseRegionFromAddress(addr)); + } + } + } + restaurantService.update(id, body); cache.flush(); - return Map.of("ok", true); + var updated = restaurantService.findById(id); + return Map.of("ok", true, "restaurant", updated); } + @DeleteMapping("/{id}") public Map delete(@PathVariable String id) { AuthUtil.requireAdmin(); diff --git a/backend-java/src/main/java/com/tasteby/controller/VideoController.java b/backend-java/src/main/java/com/tasteby/controller/VideoController.java index 92c3886..fb8dba9 100644 --- a/backend-java/src/main/java/com/tasteby/controller/VideoController.java +++ b/backend-java/src/main/java/com/tasteby/controller/VideoController.java @@ -252,6 +252,34 @@ public class VideoController { if (body.containsKey(key)) restFields.put(key, body.get(key)); } if (!restFields.isEmpty()) { + // Re-geocode if name or address changed + var existing = restaurantService.findById(restaurantId); + String newName = (String) restFields.get("name"); + String newAddr = (String) restFields.get("address"); + boolean nameChanged = newName != null && existing != null && !newName.equals(existing.getName()); + boolean addrChanged = newAddr != null && existing != null && !newAddr.equals(existing.getAddress()); + if (nameChanged || addrChanged) { + String geoName = newName != null ? newName : existing.getName(); + String geoAddr = newAddr != null ? newAddr : existing.getAddress(); + var geo = geocodingService.geocodeRestaurant(geoName, geoAddr); + if (geo != null) { + restFields.put("latitude", geo.get("latitude")); + restFields.put("longitude", geo.get("longitude")); + restFields.put("google_place_id", geo.get("google_place_id")); + if (geo.containsKey("formatted_address")) { + restFields.put("address", geo.get("formatted_address")); + } + if (geo.containsKey("rating")) restFields.put("rating", geo.get("rating")); + if (geo.containsKey("rating_count")) restFields.put("rating_count", geo.get("rating_count")); + if (geo.containsKey("phone")) restFields.put("phone", geo.get("phone")); + if (geo.containsKey("business_status")) restFields.put("business_status", geo.get("business_status")); + // Parse region from address + String addr = (String) geo.get("formatted_address"); + if (addr != null) { + restFields.put("region", GeocodingService.parseRegionFromAddress(addr)); + } + } + } restaurantService.update(restaurantId, restFields); } diff --git a/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java b/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java index afa99ff..b69f8a0 100644 --- a/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java +++ b/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java @@ -50,13 +50,20 @@ public class VideoSseController { } @PostMapping("/bulk-transcript") - public SseEmitter bulkTranscript() { + public SseEmitter bulkTranscript(@RequestBody(required = false) Map body) { AuthUtil.requireAdmin(); SseEmitter emitter = new SseEmitter(1_800_000L); // 30 min timeout + @SuppressWarnings("unchecked") + List selectedIds = body != null && body.containsKey("ids") + ? ((List) body.get("ids")).stream().map(Object::toString).toList() + : null; + executor.execute(() -> { try { - var videos = videoService.findVideosWithoutTranscript(); + var videos = selectedIds != null && !selectedIds.isEmpty() + ? videoService.findVideosByIds(selectedIds) + : videoService.findVideosWithoutTranscript(); int total = videos.size(); emit(emitter, Map.of("type", "start", "total", total)); @@ -69,6 +76,8 @@ public class VideoSseController { int success = 0; int failed = 0; + // Pass 1: 브라우저 우선 (봇 탐지 회피) + var apiNeeded = new ArrayList(); try (var session = youTubeService.createBrowserSession()) { for (int i = 0; i < total; i++) { var v = videos.get(i); @@ -76,18 +85,48 @@ public class VideoSseController { String title = (String) v.get("title"); String id = (String) v.get("id"); - emit(emitter, Map.of("type", "processing", "index", i, "title", title)); + emit(emitter, Map.of("type", "processing", "index", i, "title", title, "method", "browser")); try { - // Playwright browser first (reuse page) var result = youTubeService.getTranscriptWithPage(session.page(), videoId); - - // Fallback: thoroldvix API - if (result == null) { - log.warn("[BULK-TRANSCRIPT] Browser failed for {}, trying API", videoId); - result = youTubeService.getTranscript(videoId, "auto"); + if (result != null) { + videoService.updateTranscript(id, result.text()); + success++; + emit(emitter, Map.of("type", "done", "index", i, + "title", title, "source", result.source(), + "length", result.text().length())); + } else { + apiNeeded.add(i); + emit(emitter, Map.of("type", "skip", "index", i, + "title", title, "message", "브라우저 실패, API로 재시도 예정")); } + } catch (Exception e) { + apiNeeded.add(i); + log.warn("[BULK-TRANSCRIPT] Browser failed for {}: {}", videoId, e.getMessage()); + } + // 봇 판정 방지 랜덤 딜레이 (3~8초) + if (i < total - 1) { + int delay = ThreadLocalRandom.current().nextInt(3000, 8001); + log.info("[BULK-TRANSCRIPT] Waiting {}ms before next...", delay); + session.page().waitForTimeout(delay); + } + } + } + + // Pass 2: 브라우저 실패분만 API로 재시도 + if (!apiNeeded.isEmpty()) { + emit(emitter, Map.of("type", "api_pass", "count", apiNeeded.size())); + for (int i : apiNeeded) { + var v = videos.get(i); + String videoId = (String) v.get("video_id"); + String title = (String) v.get("title"); + String id = (String) v.get("id"); + + emit(emitter, Map.of("type", "processing", "index", i, "title", title, "method", "api")); + + try { + var result = youTubeService.getTranscriptApi(videoId, "auto"); if (result != null) { videoService.updateTranscript(id, result.text()); success++; @@ -96,22 +135,17 @@ public class VideoSseController { "length", result.text().length())); } else { failed++; + videoService.updateStatus(id, "no_transcript"); emit(emitter, Map.of("type", "error", "index", i, "title", title, "message", "자막을 찾을 수 없음")); } } catch (Exception e) { failed++; - log.error("[BULK-TRANSCRIPT] Error for {}: {}", videoId, e.getMessage()); + videoService.updateStatus(id, "no_transcript"); + log.error("[BULK-TRANSCRIPT] API error for {}: {}", videoId, e.getMessage()); emit(emitter, Map.of("type", "error", "index", i, "title", title, "message", e.getMessage())); } - - // 봇 판정 방지 랜덤 딜레이 (5~15초) - if (i < total - 1) { - int delay = ThreadLocalRandom.current().nextInt(5000, 15001); - log.info("[BULK-TRANSCRIPT] Waiting {}ms before next...", delay); - session.page().waitForTimeout(delay); - } } } @@ -126,13 +160,20 @@ public class VideoSseController { } @PostMapping("/bulk-extract") - public SseEmitter bulkExtract() { + public SseEmitter bulkExtract(@RequestBody(required = false) Map body) { AuthUtil.requireAdmin(); SseEmitter emitter = new SseEmitter(600_000L); + @SuppressWarnings("unchecked") + List selectedIds = body != null && body.containsKey("ids") + ? ((List) body.get("ids")).stream().map(Object::toString).toList() + : null; + executor.execute(() -> { try { - var rows = videoService.findVideosForBulkExtract(); + var rows = selectedIds != null && !selectedIds.isEmpty() + ? videoService.findVideosForExtractByIds(selectedIds) + : videoService.findVideosForBulkExtract(); int total = rows.size(); int totalRestaurants = 0; diff --git a/backend-java/src/main/java/com/tasteby/mapper/VideoMapper.java b/backend-java/src/main/java/com/tasteby/mapper/VideoMapper.java index 6574796..f2402da 100644 --- a/backend-java/src/main/java/com/tasteby/mapper/VideoMapper.java +++ b/backend-java/src/main/java/com/tasteby/mapper/VideoMapper.java @@ -68,6 +68,10 @@ public interface VideoMapper { List> findVideosWithoutTranscript(); + List> findVideosByIds(@Param("ids") List ids); + + List> findVideosForExtractByIds(@Param("ids") List ids); + void updateVideoRestaurantFields(@Param("videoId") String videoId, @Param("restaurantId") String restaurantId, @Param("foodsJson") String foodsJson, diff --git a/backend-java/src/main/java/com/tasteby/service/GeocodingService.java b/backend-java/src/main/java/com/tasteby/service/GeocodingService.java index 82044e5..a591453 100644 --- a/backend-java/src/main/java/com/tasteby/service/GeocodingService.java +++ b/backend-java/src/main/java/com/tasteby/service/GeocodingService.java @@ -131,6 +131,34 @@ public class GeocodingService { } } + /** + * Parse Korean address into region format "나라|시/도|구/군". + * Example: "대한민국 서울특별시 강남구 역삼동 123" → "한국|서울|강남구" + */ + public static String parseRegionFromAddress(String address) { + if (address == null || address.isBlank()) return null; + String[] parts = address.split("\\s+"); + String country = ""; + String city = ""; + String district = ""; + + for (String p : parts) { + if (p.equals("대한민국") || p.equals("South Korea")) { + country = "한국"; + } else if (p.endsWith("특별시") || p.endsWith("광역시") || p.endsWith("특별자치시")) { + city = p.replace("특별시", "").replace("광역시", "").replace("특별자치시", ""); + } else if (p.endsWith("도") && !p.endsWith("동") && p.length() <= 5) { + city = p; + } else if (p.endsWith("구") || p.endsWith("군") || (p.endsWith("시") && !city.isEmpty())) { + if (district.isEmpty()) district = p; + } + } + + if (country.isEmpty() && !city.isEmpty()) country = "한국"; + if (country.isEmpty()) return null; + return country + "|" + city + "|" + district; + } + private Map geocode(String query) { try { String response = webClient.get() diff --git a/backend-java/src/main/java/com/tasteby/service/VideoService.java b/backend-java/src/main/java/com/tasteby/service/VideoService.java index 82a56cd..81547d2 100644 --- a/backend-java/src/main/java/com/tasteby/service/VideoService.java +++ b/backend-java/src/main/java/com/tasteby/service/VideoService.java @@ -111,6 +111,22 @@ public class VideoService { return rows.stream().map(JsonUtil::lowerKeys).toList(); } + public List> findVideosByIds(List ids) { + var rows = mapper.findVideosByIds(ids); + return rows.stream().map(JsonUtil::lowerKeys).toList(); + } + + public List> findVideosForExtractByIds(List ids) { + var rows = mapper.findVideosForExtractByIds(ids); + return rows.stream().map(row -> { + var r = JsonUtil.lowerKeys(row); + Object transcript = r.get("transcript_text"); + r.put("transcript", JsonUtil.readClob(transcript)); + r.remove("transcript_text"); + return r; + }).toList(); + } + public void updateVideoRestaurantFields(String videoId, String restaurantId, String foodsJson, String evaluation, String guestsJson) { mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson); diff --git a/backend-java/src/main/java/com/tasteby/service/YouTubeService.java b/backend-java/src/main/java/com/tasteby/service/YouTubeService.java index f96faf7..fcf8e44 100644 --- a/backend-java/src/main/java/com/tasteby/service/YouTubeService.java +++ b/backend-java/src/main/java/com/tasteby/service/YouTubeService.java @@ -50,10 +50,77 @@ public class YouTubeService { } /** - * Fetch videos from a YouTube channel, page by page. - * Returns all pages merged into one list. + * Fetch videos from a YouTube channel using the uploads playlist (UC→UU). + * This returns ALL videos unlike the Search API which caps results. + * Falls back to Search API if playlist approach fails. */ public List> fetchChannelVideos(String channelId, String publishedAfter, boolean excludeShorts) { + // Convert channel ID UC... → uploads playlist UU... + String uploadsPlaylistId = "UU" + channelId.substring(2); + List> allVideos = new ArrayList<>(); + String nextPage = null; + + try { + do { + String pageToken = nextPage; + String response = webClient.get() + .uri(uriBuilder -> { + var b = uriBuilder.path("/playlistItems") + .queryParam("key", apiKey) + .queryParam("playlistId", uploadsPlaylistId) + .queryParam("part", "snippet") + .queryParam("maxResults", 50); + if (pageToken != null) b.queryParam("pageToken", pageToken); + return b.build(); + }) + .retrieve() + .bodyToMono(String.class) + .block(Duration.ofSeconds(30)); + + JsonNode data = mapper.readTree(response); + List> pageVideos = new ArrayList<>(); + + for (JsonNode item : data.path("items")) { + JsonNode snippet = item.path("snippet"); + String vid = snippet.path("resourceId").path("videoId").asText(); + String publishedAt = snippet.path("publishedAt").asText(); + + // publishedAfter 필터: 이미 스캔한 영상 이후만 + if (publishedAfter != null && publishedAt.compareTo(publishedAfter) <= 0) { + // 업로드 재생목록은 최신순이므로 이전 날짜 만나면 중단 + nextPage = null; + break; + } + + pageVideos.add(Map.of( + "video_id", vid, + "title", snippet.path("title").asText(), + "published_at", publishedAt, + "url", "https://www.youtube.com/watch?v=" + vid + )); + } + + if (excludeShorts && !pageVideos.isEmpty()) { + pageVideos = filterShorts(pageVideos); + } + allVideos.addAll(pageVideos); + + if (nextPage != null || data.has("nextPageToken")) { + nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null; + } + } while (nextPage != null); + } catch (Exception e) { + log.warn("PlaylistItems API failed for {}, falling back to Search API", channelId, e); + return fetchChannelVideosViaSearch(channelId, publishedAfter, excludeShorts); + } + + return allVideos; + } + + /** + * Fallback: fetch via Search API (may not return all videos). + */ + private List> fetchChannelVideosViaSearch(String channelId, String publishedAfter, boolean excludeShorts) { List> allVideos = new ArrayList<>(); String nextPage = null; @@ -98,7 +165,7 @@ public class YouTubeService { nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null; } catch (Exception e) { - log.error("Failed to parse YouTube API response", e); + log.error("Failed to parse YouTube Search API response", e); break; } } while (nextPage != null); @@ -108,33 +175,39 @@ public class YouTubeService { /** * Filter out YouTube Shorts (<=60s duration). + * YouTube /videos API accepts max 50 IDs per request, so we batch. */ private List> filterShorts(List> videos) { - String ids = String.join(",", videos.stream().map(v -> (String) v.get("video_id")).toList()); - String response = webClient.get() - .uri(uriBuilder -> uriBuilder.path("/videos") - .queryParam("key", apiKey) - .queryParam("id", ids) - .queryParam("part", "contentDetails") - .build()) - .retrieve() - .bodyToMono(String.class) - .block(Duration.ofSeconds(30)); + Map durations = new HashMap<>(); + List allIds = videos.stream().map(v -> (String) v.get("video_id")).toList(); - try { - JsonNode data = mapper.readTree(response); - Map durations = new HashMap<>(); - for (JsonNode item : data.path("items")) { - String duration = item.path("contentDetails").path("duration").asText(); - durations.put(item.path("id").asText(), parseDuration(duration)); + for (int i = 0; i < allIds.size(); i += 50) { + List batch = allIds.subList(i, Math.min(i + 50, allIds.size())); + String ids = String.join(",", batch); + try { + String response = webClient.get() + .uri(uriBuilder -> uriBuilder.path("/videos") + .queryParam("key", apiKey) + .queryParam("id", ids) + .queryParam("part", "contentDetails") + .build()) + .retrieve() + .bodyToMono(String.class) + .block(Duration.ofSeconds(30)); + + JsonNode data = mapper.readTree(response); + for (JsonNode item : data.path("items")) { + String duration = item.path("contentDetails").path("duration").asText(); + durations.put(item.path("id").asText(), parseDuration(duration)); + } + } catch (Exception e) { + log.warn("Failed to fetch video durations for batch starting at {}", i, e); } - return videos.stream() - .filter(v -> durations.getOrDefault(v.get("video_id"), 0) > 60) - .toList(); - } catch (Exception e) { - log.warn("Failed to filter shorts", e); - return videos; } + + return videos.stream() + .filter(v -> durations.getOrDefault(v.get("video_id"), 61) > 60) + .toList(); } private int parseDuration(String dur) { @@ -217,7 +290,7 @@ public class YouTubeService { return getTranscriptApi(videoId, mode); } - private TranscriptResult getTranscriptApi(String videoId, String mode) { + public TranscriptResult getTranscriptApi(String videoId, String mode) { TranscriptList transcriptList; try { transcriptList = transcriptApi.listTranscripts(videoId); @@ -314,11 +387,11 @@ public class YouTubeService { log.info("[TRANSCRIPT] Opening YouTube page for {}", videoId); page.navigate("https://www.youtube.com/watch?v=" + videoId, new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000)); - page.waitForTimeout(5000); + page.waitForTimeout(3000); skipAds(page); - page.waitForTimeout(2000); + page.waitForTimeout(1000); log.info("[TRANSCRIPT] Page loaded, looking for transcript button"); // Click "더보기" (expand description) @@ -372,14 +445,14 @@ public class YouTubeService { return null; } - // Wait for transcript segments to appear (max ~40s) - page.waitForTimeout(3000); - for (int attempt = 0; attempt < 12; attempt++) { - page.waitForTimeout(3000); + // Wait for transcript segments to appear (max ~15s) + page.waitForTimeout(2000); + for (int attempt = 0; attempt < 10; attempt++) { + page.waitForTimeout(1500); Object count = page.evaluate( "() => document.querySelectorAll('ytd-transcript-segment-renderer').length"); int segCount = count instanceof Number n ? n.intValue() : 0; - log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 3 + 3, segCount); + log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 1.5 + 2, segCount); if (segCount > 0) break; } @@ -434,13 +507,23 @@ public class YouTubeService { } private void skipAds(Page page) { - for (int i = 0; i < 12; i++) { + for (int i = 0; i < 30; i++) { Object adStatus = page.evaluate(""" () => { const skipBtn = document.querySelector('.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern, button.ytp-ad-skip-button-modern'); if (skipBtn) { skipBtn.click(); return 'skipped'; } const adOverlay = document.querySelector('.ytp-ad-player-overlay, .ad-showing'); - if (adOverlay) return 'playing'; + if (adOverlay) { + // 광고 중: 뮤트 + 끝으로 이동 시도 + const video = document.querySelector('video'); + if (video) { + video.muted = true; + if (video.duration && isFinite(video.duration)) { + video.currentTime = video.duration; + } + } + return 'playing'; + } const adBadge = document.querySelector('.ytp-ad-text'); if (adBadge && adBadge.textContent) return 'badge'; return 'none'; @@ -450,10 +533,10 @@ public class YouTubeService { if ("none".equals(status)) break; log.info("[TRANSCRIPT] Ad detected: {}, waiting...", status); if ("skipped".equals(status)) { - page.waitForTimeout(2000); + page.waitForTimeout(1000); break; } - page.waitForTimeout(5000); + page.waitForTimeout(1000); } } diff --git a/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml b/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml index 0ceba53..d6c08a2 100644 --- a/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml +++ b/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml @@ -143,6 +143,18 @@ longitude = #{fields.longitude}, + + google_place_id = #{fields.google_place_id}, + + + business_status = #{fields.business_status}, + + + rating = #{fields.rating}, + + + rating_count = #{fields.rating_count}, + updated_at = SYSTIMESTAMP, WHERE id = #{id} diff --git a/backend-java/src/main/resources/mybatis/mapper/SearchMapper.xml b/backend-java/src/main/resources/mybatis/mapper/SearchMapper.xml index d61e265..b373868 100644 --- a/backend-java/src/main/resources/mybatis/mapper/SearchMapper.xml +++ b/backend-java/src/main/resources/mybatis/mapper/SearchMapper.xml @@ -11,7 +11,11 @@ + + + + @@ -19,7 +23,8 @@ + + + + UPDATE video_restaurants SET foods_mentioned = #{foodsJson,jdbcType=CLOB}, diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 3552832..96f3a80 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -393,35 +393,46 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) { } }; - const startBulkStream = async (mode: "transcript" | "extract") => { + const startBulkStream = async (mode: "transcript" | "extract", ids?: string[]) => { const isTranscript = mode === "transcript"; const setRunning = isTranscript ? setBulkTranscripting : setBulkExtracting; + const hasSelection = ids && ids.length > 0; try { - const pending = isTranscript - ? await api.getBulkTranscriptPending() - : await api.getBulkExtractPending(); - if (pending.count === 0) { - alert(isTranscript ? "자막 없는 영상이 없습니다" : "추출 대기 중인 영상이 없습니다"); - return; + let count: number; + if (hasSelection) { + count = ids.length; + } else { + const pending = isTranscript + ? await api.getBulkTranscriptPending() + : await api.getBulkExtractPending(); + if (pending.count === 0) { + alert(isTranscript ? "자막 없는 영상이 없습니다" : "추출 대기 중인 영상이 없습니다"); + return; + } + count = pending.count; } const msg = isTranscript - ? `자막 없는 영상 ${pending.count}개의 트랜스크립트를 수집하시겠습니까?\n(영상 당 5~15초 랜덤 딜레이)` - : `LLM 추출이 안된 영상 ${pending.count}개를 벌크 처리하시겠습니까?\n(영상 당 3~8초 랜덤 딜레이)`; + ? `${hasSelection ? "선택한 " : "자막 없는 "}영상 ${count}개의 트랜스크립트를 수집하시겠습니까?` + : `${hasSelection ? "선택한 " : "LLM 추출이 안된 "}영상 ${count}개를 벌크 처리하시겠습니까?`; if (!confirm(msg)) return; setRunning(true); setBulkProgress({ label: isTranscript ? "벌크 자막 수집" : "벌크 LLM 추출", - total: pending.count, current: 0, currentTitle: "", results: [], + total: count, current: 0, currentTitle: "", results: [], }); const apiBase = process.env.NEXT_PUBLIC_API_URL || ""; const endpoint = isTranscript ? "/api/videos/bulk-transcript" : "/api/videos/bulk-extract"; const token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null; - const headers: Record = {}; + const headers: Record = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; - const resp = await fetch(`${apiBase}${endpoint}`, { method: "POST", headers }); + const resp = await fetch(`${apiBase}${endpoint}`, { + method: "POST", + headers, + body: hasSelection ? JSON.stringify({ ids }) : undefined, + }); if (!resp.ok) { alert(`벌크 요청 실패: ${resp.status} ${resp.statusText}`); setRunning(false); @@ -757,6 +768,20 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) { )} {isAdmin && selected.size > 0 && ( <> + +