- 벌크 자막: 브라우저 우선 + API fallback, 광고 즉시 skip, 대기 시간 단축 - 벌크 자막/추출: 선택한 영상만 처리 가능 (체크박스 선택 후 실행) - 자막 실패 시 no_transcript 상태 마킹하여 재시도 방지 - 검색 시 필터 조건 무시 (채널/장르/가격/지역/영역 초기화) - 리셋 버튼 클릭 시 검색어 입력란 초기화 - RestaurantMapper updateFields에 google_place_id, rating 등 geocoding 필드 추가 - SearchMapper에 tabling_url, catchtable_url, phone, website 필드 추가 - 식당 상세에 네이버 지도 링크 추가 - YouTubeService.getTranscriptApi public 전환 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
290 lines
13 KiB
Java
290 lines
13 KiB
Java
package com.tasteby.controller;
|
|
|
|
import com.tasteby.domain.VideoDetail;
|
|
import com.tasteby.domain.VideoSummary;
|
|
import com.tasteby.security.AuthUtil;
|
|
import com.tasteby.service.*;
|
|
import com.tasteby.util.JsonUtil;
|
|
import org.springframework.http.HttpStatus;
|
|
import org.springframework.web.bind.annotation.*;
|
|
import org.springframework.web.server.ResponseStatusException;
|
|
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
@RestController
|
|
@RequestMapping("/api/videos")
|
|
public class VideoController {
|
|
|
|
private final VideoService videoService;
|
|
private final CacheService cache;
|
|
private final YouTubeService youTubeService;
|
|
private final PipelineService pipelineService;
|
|
private final ExtractorService extractorService;
|
|
private final RestaurantService restaurantService;
|
|
private final GeocodingService geocodingService;
|
|
|
|
public VideoController(VideoService videoService, CacheService cache,
|
|
YouTubeService youTubeService, PipelineService pipelineService,
|
|
ExtractorService extractorService, RestaurantService restaurantService,
|
|
GeocodingService geocodingService) {
|
|
this.videoService = videoService;
|
|
this.cache = cache;
|
|
this.youTubeService = youTubeService;
|
|
this.pipelineService = pipelineService;
|
|
this.extractorService = extractorService;
|
|
this.restaurantService = restaurantService;
|
|
this.geocodingService = geocodingService;
|
|
}
|
|
|
|
@GetMapping
|
|
public List<VideoSummary> list(@RequestParam(required = false) String status) {
|
|
return videoService.findAll(status);
|
|
}
|
|
|
|
@GetMapping("/{id}")
|
|
public VideoDetail detail(@PathVariable String id) {
|
|
var video = videoService.findDetail(id);
|
|
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
|
return video;
|
|
}
|
|
|
|
@PutMapping("/{id}")
|
|
public Map<String, Object> updateTitle(@PathVariable String id, @RequestBody Map<String, String> body) {
|
|
AuthUtil.requireAdmin();
|
|
String title = body.get("title");
|
|
if (title == null || title.isBlank()) {
|
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title is required");
|
|
}
|
|
videoService.updateTitle(id, title);
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
@PostMapping("/{id}/skip")
|
|
public Map<String, Object> skip(@PathVariable String id) {
|
|
AuthUtil.requireAdmin();
|
|
videoService.updateStatus(id, "skip");
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
@DeleteMapping("/{id}")
|
|
public Map<String, Object> delete(@PathVariable String id) {
|
|
AuthUtil.requireAdmin();
|
|
videoService.delete(id);
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
@DeleteMapping("/{videoId}/restaurants/{restaurantId}")
|
|
public Map<String, Object> deleteVideoRestaurant(
|
|
@PathVariable String videoId, @PathVariable String restaurantId) {
|
|
AuthUtil.requireAdmin();
|
|
videoService.deleteVideoRestaurant(videoId, restaurantId);
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
|
|
@PostMapping("/{id}/fetch-transcript")
|
|
public Map<String, Object> fetchTranscript(@PathVariable String id,
|
|
@RequestParam(defaultValue = "auto") String mode) {
|
|
AuthUtil.requireAdmin();
|
|
var video = videoService.findDetail(id);
|
|
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
|
|
|
var result = youTubeService.getTranscript(video.getVideoId(), mode);
|
|
if (result == null || result.text() == null) {
|
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No transcript available");
|
|
}
|
|
|
|
videoService.updateTranscript(id, result.text());
|
|
return Map.of("ok", true, "length", result.text().length(), "source", result.source());
|
|
}
|
|
|
|
/** 클라이언트(브라우저)에서 가져온 트랜스크립트를 저장 */
|
|
@PostMapping("/{id}/upload-transcript")
|
|
public Map<String, Object> uploadTranscript(@PathVariable String id,
|
|
@RequestBody Map<String, String> body) {
|
|
AuthUtil.requireAdmin();
|
|
var video = videoService.findDetail(id);
|
|
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
|
|
|
String text = body.get("text");
|
|
if (text == null || text.isBlank()) {
|
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "text is required");
|
|
}
|
|
|
|
videoService.updateTranscript(id, text);
|
|
String source = body.getOrDefault("source", "browser");
|
|
return Map.of("ok", true, "length", text.length(), "source", source);
|
|
}
|
|
|
|
@GetMapping("/extract/prompt")
|
|
public Map<String, Object> getExtractPrompt() {
|
|
return Map.of("prompt", extractorService.getPrompt());
|
|
}
|
|
|
|
@PostMapping("/{id}/extract")
|
|
public Map<String, Object> extract(@PathVariable String id,
|
|
@RequestBody(required = false) Map<String, String> body) {
|
|
AuthUtil.requireAdmin();
|
|
var video = videoService.findDetail(id);
|
|
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
|
if (video.getTranscriptText() == null || video.getTranscriptText().isBlank()) {
|
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No transcript");
|
|
}
|
|
|
|
String customPrompt = body != null ? body.get("prompt") : null;
|
|
var videoMap = Map.<String, Object>of("id", id, "video_id", video.getVideoId(), "title", video.getTitle());
|
|
int count = pipelineService.processExtract(videoMap, video.getTranscriptText(), customPrompt);
|
|
if (count > 0) cache.flush();
|
|
return Map.of("ok", true, "restaurants_extracted", count);
|
|
}
|
|
|
|
@GetMapping("/bulk-extract/pending")
|
|
public Map<String, Object> bulkExtractPending() {
|
|
var videos = videoService.findVideosForBulkExtract();
|
|
var summary = videos.stream().map(v -> Map.of("id", v.get("id"), "title", v.get("title"))).toList();
|
|
return Map.of("count", videos.size(), "videos", summary);
|
|
}
|
|
|
|
@GetMapping("/bulk-transcript/pending")
|
|
public Map<String, Object> bulkTranscriptPending() {
|
|
var videos = videoService.findVideosWithoutTranscript();
|
|
var summary = videos.stream().map(v -> Map.of("id", v.get("id"), "title", v.get("title"))).toList();
|
|
return Map.of("count", videos.size(), "videos", summary);
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
@PostMapping("/{videoId}/restaurants/manual")
|
|
public Map<String, Object> addManualRestaurant(@PathVariable String videoId,
|
|
@RequestBody Map<String, Object> body) {
|
|
AuthUtil.requireAdmin();
|
|
String name = (String) body.get("name");
|
|
if (name == null || name.isBlank()) {
|
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "name is required");
|
|
}
|
|
|
|
// Geocode
|
|
var geo = geocodingService.geocodeRestaurant(name, (String) body.get("address"));
|
|
var data = new HashMap<String, Object>();
|
|
data.put("name", name);
|
|
data.put("address", geo != null ? geo.get("formatted_address") : body.get("address"));
|
|
data.put("region", body.get("region"));
|
|
data.put("latitude", geo != null ? geo.get("latitude") : null);
|
|
data.put("longitude", geo != null ? geo.get("longitude") : null);
|
|
data.put("cuisine_type", body.get("cuisine_type"));
|
|
data.put("price_range", body.get("price_range"));
|
|
data.put("google_place_id", geo != null ? geo.get("google_place_id") : null);
|
|
data.put("phone", geo != null ? geo.get("phone") : null);
|
|
data.put("website", geo != null ? geo.get("website") : null);
|
|
data.put("business_status", geo != null ? geo.get("business_status") : null);
|
|
data.put("rating", geo != null ? geo.get("rating") : null);
|
|
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
|
|
|
|
String restId = restaurantService.upsert(data);
|
|
|
|
// Parse foods and guests
|
|
List<String> foods = null;
|
|
Object foodsRaw = body.get("foods_mentioned");
|
|
if (foodsRaw instanceof List<?>) {
|
|
foods = ((List<?>) foodsRaw).stream().map(Object::toString).toList();
|
|
} else if (foodsRaw instanceof String s && !s.isBlank()) {
|
|
foods = List.of(s.split("\\s*,\\s*"));
|
|
}
|
|
|
|
List<String> guests = null;
|
|
Object guestsRaw = body.get("guests");
|
|
if (guestsRaw instanceof List<?>) {
|
|
guests = ((List<?>) guestsRaw).stream().map(Object::toString).toList();
|
|
} else if (guestsRaw instanceof String s && !s.isBlank()) {
|
|
guests = List.of(s.split("\\s*,\\s*"));
|
|
}
|
|
|
|
String evaluation = body.get("evaluation") instanceof String s ? s : null;
|
|
|
|
restaurantService.linkVideoRestaurant(videoId, restId, foods, evaluation, guests);
|
|
cache.flush();
|
|
return Map.of("ok", true, "restaurant_id", restId);
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
@PutMapping("/{videoId}/restaurants/{restaurantId}")
|
|
public Map<String, Object> updateVideoRestaurant(@PathVariable String videoId,
|
|
@PathVariable String restaurantId,
|
|
@RequestBody Map<String, Object> body) {
|
|
AuthUtil.requireAdmin();
|
|
|
|
// Update link fields (foods_mentioned, evaluation, guests)
|
|
List<String> foods = null;
|
|
Object foodsRaw = body.get("foods_mentioned");
|
|
if (foodsRaw instanceof List<?>) {
|
|
foods = ((List<?>) foodsRaw).stream().map(Object::toString).toList();
|
|
} else if (foodsRaw instanceof String s && !s.isBlank()) {
|
|
foods = List.of(s.split("\\s*,\\s*"));
|
|
}
|
|
|
|
List<String> guests = null;
|
|
Object guestsRaw = body.get("guests");
|
|
if (guestsRaw instanceof List<?>) {
|
|
guests = ((List<?>) guestsRaw).stream().map(Object::toString).toList();
|
|
} else if (guestsRaw instanceof String s && !s.isBlank()) {
|
|
guests = List.of(s.split("\\s*,\\s*"));
|
|
}
|
|
|
|
// evaluation must be valid JSON for DB IS JSON constraint
|
|
String evaluationJson = null;
|
|
Object evalRaw = body.get("evaluation");
|
|
if (evalRaw instanceof String s && !s.isBlank()) {
|
|
evaluationJson = s.trim().startsWith("{") || s.trim().startsWith("\"") ? s : JsonUtil.toJson(s);
|
|
} else if (evalRaw instanceof Map<?, ?>) {
|
|
evaluationJson = JsonUtil.toJson(evalRaw);
|
|
}
|
|
String foodsJson = foods != null ? JsonUtil.toJson(foods) : null;
|
|
String guestsJson = guests != null ? JsonUtil.toJson(guests) : null;
|
|
videoService.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluationJson, guestsJson);
|
|
|
|
// Update restaurant fields if provided
|
|
var restFields = new HashMap<String, Object>();
|
|
for (var key : List.of("name", "address", "region", "cuisine_type", "price_range")) {
|
|
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);
|
|
}
|
|
|
|
cache.flush();
|
|
return Map.of("ok", true);
|
|
}
|
|
}
|