Retire Opal/YtVideo pipeline; ChannelVideo is the single video master

The legacy Opal content pipeline (YtVideo + ScriptGen + OpalDraft/Final/
FinalAsset, driven by AnalysisWorkflowService via hardcoded Google Docs)
is no longer used. The active flow is ChannelVideo: collect -> curate
(board) -> rework -> publish.

Removed:
- service: AnalysisWorkflowService, YtVideoService, external/ExternalApiService(+Impl/Stub)
- web: YtVideoController, VideoActionController (/api/videos), video_detail.html
- web/dto: Video{Response,SearchCondition,AddRequest,DetailResponse},
  FinalAssetResponse, OpalDraftResponse, DraftGenerateRequest
- domain/video: YtVideo, YtVideoRepository, dto/Video{List,Detail}Response
- domain/script: ScriptGen(+Repository)
- domain/opal: OpalDraft/OpalFinal/OpalFinalAsset(+Repositories, dto)

Preserved the active YouTube search by extracting searchYoutubeVideos()
into a new dedicated YoutubeSearchService (no Opal deps); rewired
YoutubeSearchApiController. WebController drops the /videos/{id} Opal
detail route + YtVideoService dependency.

DB note: ddl-auto=update never drops tables, so yt_video / scriptgen /
opal_* remain as orphaned tables (harmless, no data loss). Verified by
clean compileJava + reference sweep across java/html/yml.
This commit is contained in:
hehih 2026-05-30 19:51:53 +09:00
parent e862498f96
commit 9bd7e80542
31 changed files with 68 additions and 2097 deletions

View File

@ -2,7 +2,10 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"PowerShell($env:JAVA_HOME=\"D:\\\\Development\\\\app\\\\JDK\\\\jdk-21.0.5\"; .\\\\gradlew.bat compileJava --console=plain 2>&1 | Select-Object -Last 15)", "PowerShell($env:JAVA_HOME=\"D:\\\\Development\\\\app\\\\JDK\\\\jdk-21.0.5\"; .\\\\gradlew.bat compileJava --console=plain 2>&1 | Select-Object -Last 15)",
"PowerShell($env:JAVA_HOME=\"D:\\\\Development\\\\app\\\\JDK\\\\jdk-21.0.5\"; .\\\\gradlew.bat clean compileJava --console=plain 2>&1 | Select-Object -Last 20)" "PowerShell($env:JAVA_HOME=\"D:\\\\Development\\\\app\\\\JDK\\\\jdk-21.0.5\"; .\\\\gradlew.bat clean compileJava --console=plain 2>&1 | Select-Object -Last 20)",
"Bash(git rm *)",
"Bash(git add *)",
"Bash(git -c user.name=hehih -c user.email=hehihoho86@gmail.com commit -q -m 'Retire Opal/YtVideo pipeline; ChannelVideo is the single video master *)"
] ]
} }
} }

View File

@ -1,65 +0,0 @@
package com.hlab.yanalyst.domain.opal;
import com.hlab.yanalyst.domain.video.YtVideo;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import java.time.LocalDateTime;
@Entity
@Table(name = "opal_draft")
@Getter
@Setter
@NoArgsConstructor
public class OpalDraft {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "draft_id")
private Long draftId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "video_id", nullable = false)
private YtVideo video;
@Type(JsonBinaryType.class)
@Column(name = "request_payload_json", columnDefinition = "jsonb")
private String requestPayloadJson;
@Column(name = "response_text", columnDefinition = "TEXT")
private String responseText;
@Column(name = "old_script_summary", columnDefinition = "TEXT")
private String oldScriptSummary;
@Column(name = "new_script_summary", columnDefinition = "TEXT")
private String newScriptSummary;
@Column(name = "user_feedback", columnDefinition = "TEXT")
private String userFeedback;
@Column(name = "version_no", nullable = false)
private Integer versionNo;
@Column(name = "is_accepted", nullable = false)
private Boolean isAccepted = false;
@Column(name = "accepted_at")
private LocalDateTime acceptedAt;
@Column(name = "status", nullable = false, length = 20)
private String status = "SUCCESS";
@Column(name = "error_msg", columnDefinition = "TEXT")
private String errorMsg;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

View File

@ -1,9 +0,0 @@
package com.hlab.yanalyst.domain.opal;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface OpalDraftRepository extends JpaRepository<OpalDraft, Long> {
List<OpalDraft> findByVideo_VideoIdOrderByVersionNoDesc(Long videoId);
Integer countByVideo_VideoId(Long videoId);
}

View File

@ -1,45 +0,0 @@
package com.hlab.yanalyst.domain.opal;
import com.hlab.yanalyst.domain.video.YtVideo;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "opal_final")
@Getter
@Setter
@NoArgsConstructor
public class OpalFinal {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "final_id")
private Long finalId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "video_id", nullable = false)
private YtVideo video;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "draft_id", nullable = false)
private OpalDraft draft;
@Column(name = "final_script_text", nullable = false, columnDefinition = "TEXT")
private String finalScriptText;
@Column(name = "is_active", nullable = false)
private Boolean isActive = false;
@CreationTimestamp
@Column(name = "finalized_at", nullable = false)
private LocalDateTime finalizedAt;
@Column(name = "status", nullable = false, length = 20)
private String status = "FINALIZED";
}

View File

@ -1,54 +0,0 @@
package com.hlab.yanalyst.domain.opal;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import java.time.LocalDateTime;
@Entity
@Table(name = "opal_final_asset")
@Getter
@Setter
@NoArgsConstructor
public class OpalFinalAsset {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "asset_id")
private Long assetId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "final_id", nullable = false)
private OpalFinal opalFinal;
@Type(JsonBinaryType.class)
@Column(name = "asset_json", nullable = false, columnDefinition = "jsonb")
private String assetJson;
@Column(name = "title", columnDefinition = "TEXT")
private String title;
@Column(name = "summary", columnDefinition = "TEXT")
private String summary;
@Type(JsonBinaryType.class)
@Column(name = "timeline", columnDefinition = "jsonb")
private String timeline;
@Column(name = "video_prompt", columnDefinition = "TEXT")
private String videoPrompt;
@Type(JsonBinaryType.class)
@Column(name = "image_urls", columnDefinition = "jsonb")
private String imageUrls;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

View File

@ -1,8 +0,0 @@
package com.hlab.yanalyst.domain.opal;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface OpalFinalAssetRepository extends JpaRepository<OpalFinalAsset, Long> {
Optional<OpalFinalAsset> findByOpalFinal_FinalId(Long finalId);
}

View File

@ -1,10 +0,0 @@
package com.hlab.yanalyst.domain.opal;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.List;
public interface OpalFinalRepository extends JpaRepository<OpalFinal, Long> {
Optional<OpalFinal> findByVideo_VideoIdAndIsActiveTrue(Long videoId);
List<OpalFinal> findByVideo_VideoId(Long videoId);
}

View File

@ -1,15 +0,0 @@
package com.hlab.yanalyst.domain.opal.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OpalDraftResponseDto {
private String oldScriptSummary;
private String newScriptSummary;
}

View File

@ -1,58 +0,0 @@
package com.hlab.yanalyst.domain.script;
import com.hlab.yanalyst.domain.video.YtVideo;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "script_gen")
@Getter
@Setter
@NoArgsConstructor
public class ScriptGen {
@Id
@Column(name = "video_id")
private Long videoId;
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "video_id")
private YtVideo video;
@Type(JsonBinaryType.class)
@Column(name = "request_payload_json", columnDefinition = "jsonb")
private String requestPayloadJson;
@Column(name = "response_text", nullable = false, columnDefinition = "TEXT")
private String responseText;
@Column(name = "model_name", length = 100)
private String modelName;
@Column(name = "latency_ms")
private Integer latencyMs;
@Column(name = "status", nullable = false, length = 20)
private String status = "SUCCESS";
@Column(name = "error_msg", columnDefinition = "TEXT")
private String errorMsg;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

View File

@ -1,6 +0,0 @@
package com.hlab.yanalyst.domain.script;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ScriptGenRepository extends JpaRepository<ScriptGen, Long> {
}

View File

@ -1,84 +0,0 @@
package com.hlab.yanalyst.domain.video;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "yt_video")
@Getter
@Setter
@NoArgsConstructor
public class YtVideo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "video_id")
private Long videoId;
@Column(name = "youtube_video_id", nullable = false, unique = true, length = 32)
private String youtubeVideoId;
@Column(name = "video_url", nullable = false, columnDefinition = "TEXT")
private String videoUrl;
@Column(name = "opening_script", columnDefinition = "TEXT")
private String openingScript;
@Column(name = "title", columnDefinition = "TEXT")
private String title;
@Column(name = "channel_title", columnDefinition = "TEXT")
private String channelTitle;
@Column(name = "published_at")
private LocalDateTime publishedAt;
@Column(name = "duration_sec")
private Integer durationSec;
@Column(name = "view_count")
private Long viewCount;
@Column(name = "like_count")
private Long likeCount;
@Column(name = "comment_count")
private Long commentCount;
@Column(name = "subscriber_count")
private Long subscriberCount;
@Column(name = "views_per_hour", precision = 18, scale = 2)
private BigDecimal viewsPerHour;
@Column(name = "views_per_sub_ratio", precision = 18, scale = 2)
private BigDecimal viewsPerSubRatio;
@Column(name = "thumbnail_url", columnDefinition = "TEXT")
private String thumbnailUrl;
@Column(name = "status", nullable = false, length = 30)
private String status = "CRAWLED";
@Column(name = "last_crawled_at", nullable = false)
private LocalDateTime lastCrawledAt = LocalDateTime.now();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Column(name = "is_completed")
private Boolean isCompleted = false;
}

View File

@ -1,11 +0,0 @@
package com.hlab.yanalyst.domain.video;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface YtVideoRepository extends JpaRepository<YtVideo, Long>, org.springframework.data.jpa.repository.JpaSpecificationExecutor<YtVideo> {
Page<YtVideo> findAllByOrderByLastCrawledAtDesc(Pageable pageable);
java.util.Optional<YtVideo> findByYoutubeVideoId(String youtubeVideoId);
}

View File

@ -1,26 +0,0 @@
package com.hlab.yanalyst.domain.video.dto;
import com.hlab.yanalyst.domain.video.YtVideo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class VideoDetailResponse {
private VideoListResponse metadata;
private String videoUrl;
private Integer durationSec;
private Long likeCount;
private Long commentCount;
private LocalDateTime publishedAt;
public static VideoDetailResponse from(YtVideo video) {
VideoDetailResponse response = new VideoDetailResponse();
response.setMetadata(VideoListResponse.from(video));
response.setVideoUrl(video.getVideoUrl());
response.setDurationSec(video.getDurationSec());
response.setLikeCount(video.getLikeCount());
response.setCommentCount(video.getCommentCount());
response.setPublishedAt(video.getPublishedAt());
return response;
}
}

View File

@ -1,38 +0,0 @@
package com.hlab.yanalyst.domain.video.dto;
import com.hlab.yanalyst.domain.video.YtVideo;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class VideoListResponse {
private Long videoId;
private String youtubeVideoId;
private String title;
private String channelTitle;
private String thumbnailUrl;
private Long viewCount;
private Long subscriberCount;
private BigDecimal viewsPerSubRatio;
private BigDecimal viewsPerHour;
private String status;
private LocalDateTime lastCrawledAt;
public static VideoListResponse from(YtVideo video) {
VideoListResponse response = new VideoListResponse();
response.setVideoId(video.getVideoId());
response.setYoutubeVideoId(video.getYoutubeVideoId());
response.setTitle(video.getTitle());
response.setChannelTitle(video.getChannelTitle());
response.setThumbnailUrl(video.getThumbnailUrl());
response.setViewCount(video.getViewCount());
response.setSubscriberCount(video.getSubscriberCount());
response.setViewsPerSubRatio(video.getViewsPerSubRatio());
response.setViewsPerHour(video.getViewsPerHour());
response.setStatus(video.getStatus());
response.setLastCrawledAt(video.getLastCrawledAt());
return response;
}
}

View File

@ -1,180 +0,0 @@
package com.hlab.yanalyst.service;
import com.hlab.yanalyst.domain.opal.*;
import com.hlab.yanalyst.domain.script.ScriptGen;
import com.hlab.yanalyst.domain.script.ScriptGenRepository;
import com.hlab.yanalyst.domain.video.YtVideo;
import com.hlab.yanalyst.domain.video.YtVideoRepository;
import com.hlab.yanalyst.service.external.ExternalApiService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional
public class AnalysisWorkflowService {
private final YtVideoRepository ytVideoRepository;
private final ScriptGenRepository scriptGenRepository;
private final OpalDraftRepository opalDraftRepository;
private final OpalFinalRepository opalFinalRepository;
private final OpalFinalAssetRepository opalFinalAssetRepository;
private final ExternalApiService externalApiService;
private final ObjectMapper objectMapper;
public ScriptGen generateScript(Long videoId) {
YtVideo video = ytVideoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found"));
String scriptContent = externalApiService.generateScript(video.getVideoUrl());
ScriptGen scriptGen = scriptGenRepository.findById(videoId).orElse(new ScriptGen());
if (scriptGen.getVideoId() == null) {
scriptGen.setVideo(video);
}
scriptGen.setResponseText(scriptContent);
scriptGen.setStatus("SUCCESS");
// Update video status
video.setStatus("SCRIPT_READY");
return scriptGenRepository.save(scriptGen);
}
public OpalDraft generateDraft(Long videoId, String feedback, String mode) {
YtVideo video = ytVideoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found"));
ScriptGen scriptGen = scriptGenRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Script not found"));
var draftDto = externalApiService.generateOpalDraft(scriptGen.getResponseText(), feedback, mode);
OpalDraft draft = new OpalDraft();
draft.setVideo(video);
draft.setOldScriptSummary(draftDto.getOldScriptSummary());
draft.setNewScriptSummary(draftDto.getNewScriptSummary());
// For compatibility, we can concatenate or just leave responseText empty/null, or use New Summary as main response
draft.setResponseText(draftDto.getNewScriptSummary());
draft.setUserFeedback(feedback);
Integer maxVersion = opalDraftRepository.findByVideo_VideoIdOrderByVersionNoDesc(videoId)
.stream().findFirst().map(OpalDraft::getVersionNo).orElse(0);
draft.setVersionNo(maxVersion + 1);
draft.setStatus("SUCCESS");
video.setStatus("DRAFTING");
return opalDraftRepository.save(draft);
}
public OpalFinal acceptDraft(Long videoId, Long draftId) {
YtVideo video = ytVideoRepository.findById(videoId).orElseThrow();
OpalDraft draft = opalDraftRepository.findById(draftId).orElseThrow();
if (!draft.getVideo().getVideoId().equals(videoId)) {
throw new IllegalArgumentException("Draft does not match video");
}
// Deactivate existing active final
opalFinalRepository.findByVideo_VideoIdAndIsActiveTrue(videoId)
.ifPresent(existing -> {
existing.setIsActive(false);
opalFinalRepository.save(existing);
});
// Mark draft accepted
draft.setIsAccepted(true);
draft.setAcceptedAt(LocalDateTime.now());
opalDraftRepository.save(draft);
OpalFinal opalFinal = new OpalFinal();
opalFinal.setVideo(video);
opalFinal.setDraft(draft);
opalFinal.setFinalScriptText(draft.getResponseText());
opalFinal.setIsActive(true);
return opalFinalRepository.save(opalFinal);
}
public OpalFinalAsset generateFinalAsset(Long videoId) {
YtVideo video = ytVideoRepository.findById(videoId).orElseThrow();
OpalFinal activeFinal = opalFinalRepository.findByVideo_VideoIdAndIsActiveTrue(videoId)
.orElse(null);
if (activeFinal == null) {
OpalDraft latestDraft = opalDraftRepository.findByVideo_VideoIdOrderByVersionNoDesc(videoId)
.stream().findFirst()
.orElseThrow(() -> new IllegalArgumentException("No active final script and no drafts found. Please generate a draft first."));
activeFinal = new OpalFinal();
activeFinal.setVideo(video);
activeFinal.setDraft(latestDraft);
activeFinal.setIsActive(true);
activeFinal.setFinalScriptText("");
activeFinal = opalFinalRepository.save(activeFinal);
}
// 1. Fetch updated final script from external source (GDoc)
String updatedFinalScript = externalApiService.fetchFinalScript();
// 2. Update and save active final script
activeFinal.setFinalScriptText(updatedFinalScript);
opalFinalRepository.save(activeFinal);
// 3. Generate asset metadata
var assetMap = externalApiService.generateFinalAsset(activeFinal.getFinalScriptText());
OpalFinalAsset asset = new OpalFinalAsset();
asset.setOpalFinal(activeFinal);
try {
asset.setAssetJson(objectMapper.writeValueAsString(assetMap));
asset.setTitle((String) assetMap.get("title"));
asset.setSummary((String) assetMap.get("summary"));
asset.setTimeline(objectMapper.writeValueAsString(assetMap.get("timeline")));
asset.setVideoPrompt((String) assetMap.get("video_prompt"));
asset.setImageUrls(objectMapper.writeValueAsString(assetMap.get("image_urls")));
} catch (Exception e) {
throw new RuntimeException("JSON processing error", e);
}
video.setStatus("FINALIZED");
return opalFinalAssetRepository.save(asset);
}
public boolean toggleComplete(Long videoId) {
YtVideo video = ytVideoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found"));
boolean newState = video.getIsCompleted() == null ? true : !video.getIsCompleted();
video.setIsCompleted(newState);
ytVideoRepository.save(video);
return newState;
}
public void updateFinalAsset(Long videoId, String newText) {
OpalFinal opalFinal = opalFinalRepository.findByVideo_VideoIdAndIsActiveTrue(videoId)
.orElseThrow(() -> new IllegalArgumentException("No active final asset found for videoId: " + videoId));
opalFinal.setFinalScriptText(newText);
opalFinalRepository.save(opalFinal);
}
public void generateOpening(Long videoId, String docId) {
YtVideo video = ytVideoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found"));
String openingScript = externalApiService.fetchOpeningScript(docId);
video.setOpeningScript(openingScript);
ytVideoRepository.save(video);
}
public void resetOpening(Long videoId) {
YtVideo video = ytVideoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found"));
video.setOpeningScript(null);
ytVideoRepository.save(video);
}
}

View File

@ -1,210 +1,53 @@
package com.hlab.yanalyst.service; package com.hlab.yanalyst.service;
import com.hlab.yanalyst.domain.opal.OpalDraftRepository; import com.fasterxml.jackson.databind.JsonNode;
import com.hlab.yanalyst.domain.opal.OpalFinal; import com.hlab.yanalyst.web.dto.YoutubeSearchCondition;
import com.hlab.yanalyst.domain.opal.OpalFinalAssetRepository; import com.hlab.yanalyst.web.dto.YoutubeSearchPageDto;
import com.hlab.yanalyst.domain.opal.OpalFinalRepository; import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
import com.hlab.yanalyst.domain.script.ScriptGenRepository;
import com.hlab.yanalyst.domain.video.YtVideo;
import com.hlab.yanalyst.domain.video.YtVideoRepository;
import com.hlab.yanalyst.web.dto.*;
import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* YouTube Data API 검색 지역/채널/키워드/포맷(Shorts·롱폼)으로 영상을 찾아
* 조회수·구독자·길이·해시태그까지 보강한 결과를 반환한다.
* 결과는 {@link com.hlab.yanalyst.domain.channel.SearchCollectionService} 통해 수집함(ChannelVideo) 저장된다.
*/
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional(readOnly = true) public class YoutubeSearchService {
public class YtVideoService {
private final YtVideoRepository ytVideoRepository; private final RestTemplate restTemplate;
private final ScriptGenRepository scriptGenRepository;
private final OpalDraftRepository opalDraftRepository;
private final OpalFinalRepository opalFinalRepository;
private final OpalFinalAssetRepository opalFinalAssetRepository;
private final org.springframework.web.client.RestTemplate restTemplate;
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
@org.springframework.beans.factory.annotation.Value("${youtube.api.key}") @Value("${youtube.api.key}")
private String youtubeApiKey; private String youtubeApiKey;
@Transactional public YoutubeSearchPageDto searchYoutubeVideos(YoutubeSearchCondition condition) {
public YtVideo saveVideoFromUrl(String url) {
String videoId = extractVideoId(url);
// Check if exists
java.util.Optional<YtVideo> existing = ytVideoRepository.findByYoutubeVideoId(videoId);
if (existing.isPresent()) {
throw new IllegalArgumentException("Video already exists with ID: " + videoId);
}
String apiUrl = "https://www.googleapis.com/youtube/v3/videos";
org.springframework.web.util.UriComponentsBuilder builder = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(apiUrl)
.queryParam("part", "snippet,statistics,contentDetails")
.queryParam("id", videoId)
.queryParam("key", youtubeApiKey);
try {
com.fasterxml.jackson.databind.JsonNode root = restTemplate.getForObject(builder.toUriString(), com.fasterxml.jackson.databind.JsonNode.class);
com.fasterxml.jackson.databind.JsonNode items = root.path("items");
if (items.isEmpty()) {
throw new IllegalArgumentException("Video not found for ID: " + videoId);
}
com.fasterxml.jackson.databind.JsonNode item = items.get(0);
com.fasterxml.jackson.databind.JsonNode snippet = item.get("snippet");
com.fasterxml.jackson.databind.JsonNode statistics = item.get("statistics");
com.fasterxml.jackson.databind.JsonNode contentDetails = item.get("contentDetails");
YtVideo video = new YtVideo();
video.setYoutubeVideoId(videoId);
video.setVideoUrl("https://www.youtube.com/watch?v=" + videoId);
video.setTitle(snippet.get("title").asText());
video.setChannelTitle(snippet.get("channelTitle").asText());
video.setThumbnailUrl(snippet.get("thumbnails").has("maxres")
? snippet.get("thumbnails").get("maxres").get("url").asText()
: snippet.get("thumbnails").get("high").get("url").asText());
video.setPublishedAt(LocalDateTime.parse(snippet.get("publishedAt").asText(), java.time.format.DateTimeFormatter.ISO_DATE_TIME));
if (statistics.has("viewCount")) video.setViewCount(Long.parseLong(statistics.get("viewCount").asText()));
if (statistics.has("likeCount")) video.setLikeCount(Long.parseLong(statistics.get("likeCount").asText()));
if (statistics.has("commentCount")) video.setCommentCount(Long.parseLong(statistics.get("commentCount").asText()));
String duration = contentDetails.get("duration").asText();
video.setDurationSec((int) java.time.Duration.parse(duration).getSeconds());
// Set simple defaults for calculated fields
video.setViewsPerHour(java.math.BigDecimal.ZERO);
video.setViewsPerSubRatio(java.math.BigDecimal.ZERO);
video.setSubscriberCount(0L); // We don't have channel stats here in one call
video.setStatus("CRAWLED");
return ytVideoRepository.save(video);
} catch (Exception e) {
throw new RuntimeException("Failed to fetch video details", e);
}
}
private String extractVideoId(String url) {
// Simple extraction for v= parameter or short url
if (url.contains("v=")) {
String[] parts = url.split("v=");
if (parts.length > 1) {
String id = parts[1];
int ampIndex = id.indexOf("&");
if (ampIndex != -1) {
return id.substring(0, ampIndex);
}
return id;
}
} else if (url.contains("youtu.be/")) {
String[] parts = url.split("youtu.be/");
if (parts.length > 1) {
return parts[1].split("\\?")[0];
}
}
return url; // Assume ID if not URL format, or fail later
}
public Page<VideoResponse> getVideos(VideoSearchCondition condition, Pageable pageable) {
Specification<YtVideo> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (StringUtils.hasText(condition.getStatus())) {
predicates.add(cb.equal(root.get("status"), condition.getStatus()));
}
if (StringUtils.hasText(condition.getKeyword())) {
String likePattern = "%" + condition.getKeyword() + "%";
predicates.add(cb.or(
cb.like(root.get("title"), likePattern),
cb.like(root.get("channelTitle"), likePattern)
));
}
if (condition.getStartDate() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("lastCrawledAt"), condition.getStartDate().atStartOfDay()));
}
if (condition.getEndDate() != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("lastCrawledAt"), condition.getEndDate().atTime(23, 59, 59)));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
return ytVideoRepository.findAll(spec, pageable).map(VideoResponse::new);
}
public VideoDetailResponse getVideoDetail(Long videoId) {
YtVideo video = ytVideoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found"));
VideoDetailResponse response = new VideoDetailResponse(video);
scriptGenRepository.findById(videoId).ifPresent(scriptGen -> {
response.setScriptContent(scriptGen.getResponseText());
response.setScriptStatus(scriptGen.getStatus());
});
return response;
}
public List<OpalDraftResponse> getDrafts(Long videoId) {
return opalDraftRepository.findByVideo_VideoIdOrderByVersionNoDesc(videoId)
.stream()
.map(OpalDraftResponse::new)
.collect(Collectors.toList());
}
public FinalAssetResponse getFinalAsset(Long videoId) {
return opalFinalRepository.findByVideo_VideoIdAndIsActiveTrue(videoId)
.map(opalFinal -> {
var asset = opalFinalAssetRepository.findById(opalFinal.getFinalId()).orElse(null);
return new FinalAssetResponse(opalFinal, asset);
})
.orElse(null);
}
@Transactional
public void updateFinalAsset(Long videoId, String newText) {
OpalFinal opalFinal = opalFinalRepository.findByVideo_VideoIdAndIsActiveTrue(videoId)
.orElseThrow(() -> new IllegalArgumentException("No active final asset found for videoId: " + videoId));
opalFinal.setFinalScriptText(newText);
// opalFinalRepository.save(opalFinal); // Transactional handles save
}
public com.hlab.yanalyst.web.dto.YoutubeSearchPageDto searchYoutubeVideos(YoutubeSearchCondition condition) {
List<YoutubeSearchResultDto> results = new ArrayList<>(); List<YoutubeSearchResultDto> results = new ArrayList<>();
java.util.Map<String, String> nextTokens = new java.util.HashMap<>(); java.util.Map<String, String> nextTokens = new java.util.HashMap<>();
List<String> regions = condition.getRegions() != null && !condition.getRegions().isEmpty() List<String> regions = condition.getRegions() != null && !condition.getRegions().isEmpty()
? condition.getRegions() ? condition.getRegions()
: List.of("JP", "US"); : List.of("JP", "US");
List<String> channelIds = condition.getChannelIds(); List<String> channelIds = condition.getChannelIds();
boolean isChannelSearch = channelIds != null && !channelIds.isEmpty(); boolean isChannelSearch = channelIds != null && !channelIds.isEmpty();
List<String> searchTargets = isChannelSearch ? channelIds : regions; List<String> searchTargets = isChannelSearch ? channelIds : regions;
String publishedAfter = null; String publishedAfter = null;
if (condition.getPeriodDays() != null && condition.getPeriodDays() > 0) { if (condition.getPeriodDays() != null && condition.getPeriodDays() > 0) {
java.time.ZonedDateTime date = java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC).minusDays(condition.getPeriodDays()); java.time.ZonedDateTime date = java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC).minusDays(condition.getPeriodDays());
publishedAfter = date.format(java.time.format.DateTimeFormatter.ISO_INSTANT); publishedAfter = date.format(DateTimeFormatter.ISO_INSTANT);
} }
String duration = "short"; String duration = "short";
@ -213,12 +56,12 @@ public class YtVideoService {
} }
String searchApiUrl = "https://www.googleapis.com/youtube/v3/search"; String searchApiUrl = "https://www.googleapis.com/youtube/v3/search";
List<com.fasterxml.jackson.databind.JsonNode> allItems = new ArrayList<>(); List<JsonNode> allItems = new ArrayList<>();
for (String target : searchTargets) { for (String target : searchTargets) {
String order = StringUtils.hasText(condition.getKeyword()) ? "relevance" : "date"; String order = StringUtils.hasText(condition.getKeyword()) ? "relevance" : "date";
org.springframework.web.util.UriComponentsBuilder builder = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(searchApiUrl) UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(searchApiUrl)
.queryParam("part", "snippet") .queryParam("part", "snippet")
.queryParam("maxResults", 50) .queryParam("maxResults", 50)
.queryParam("type", "video") .queryParam("type", "video")
@ -251,13 +94,13 @@ public class YtVideoService {
} }
try { try {
com.fasterxml.jackson.databind.JsonNode root = restTemplate.getForObject(builder.build().encode().toUri(), com.fasterxml.jackson.databind.JsonNode.class); JsonNode root = restTemplate.getForObject(builder.build().encode().toUri(), JsonNode.class);
if (root != null) { if (root != null) {
if (root.has("nextPageToken")) { if (root.has("nextPageToken")) {
nextTokens.put(target, root.get("nextPageToken").asText()); nextTokens.put(target, root.get("nextPageToken").asText());
} }
if (root.has("items")) { if (root.has("items")) {
for (com.fasterxml.jackson.databind.JsonNode item : root.get("items")) { for (JsonNode item : root.get("items")) {
allItems.add(item); allItems.add(item);
} }
} }
@ -268,20 +111,20 @@ public class YtVideoService {
} }
java.util.Map<String, YoutubeSearchResultDto> uniqueVideos = new java.util.HashMap<>(); java.util.Map<String, YoutubeSearchResultDto> uniqueVideos = new java.util.HashMap<>();
for (com.fasterxml.jackson.databind.JsonNode item : allItems) { for (JsonNode item : allItems) {
String videoId = item.get("id").get("videoId").asText(); String videoId = item.get("id").get("videoId").asText();
if (!uniqueVideos.containsKey(videoId)) { if (!uniqueVideos.containsKey(videoId)) {
com.fasterxml.jackson.databind.JsonNode snippet = item.get("snippet"); JsonNode snippet = item.get("snippet");
YoutubeSearchResultDto dto = new YoutubeSearchResultDto(); YoutubeSearchResultDto dto = new YoutubeSearchResultDto();
dto.setVideoId(videoId); dto.setVideoId(videoId);
dto.setTitle(snippet.get("title").asText()); dto.setTitle(snippet.get("title").asText());
dto.setChannelId(snippet.get("channelId").asText()); dto.setChannelId(snippet.get("channelId").asText());
dto.setChannelTitle(snippet.get("channelTitle").asText()); dto.setChannelTitle(snippet.get("channelTitle").asText());
String publishedAtStr = snippet.get("publishedAt").asText(); String publishedAtStr = snippet.get("publishedAt").asText();
dto.setPublishedAt(LocalDateTime.parse(publishedAtStr, java.time.format.DateTimeFormatter.ISO_DATE_TIME)); dto.setPublishedAt(LocalDateTime.parse(publishedAtStr, DateTimeFormatter.ISO_DATE_TIME));
if (snippet.has("thumbnails") && snippet.get("thumbnails").has("high")) { if (snippet.has("thumbnails") && snippet.get("thumbnails").has("high")) {
dto.setThumbnailUrl(snippet.get("thumbnails").get("high").get("url").asText()); dto.setThumbnailUrl(snippet.get("thumbnails").get("high").get("url").asText());
} else if (snippet.has("thumbnails") && snippet.get("thumbnails").has("default")) { } else if (snippet.has("thumbnails") && snippet.get("thumbnails").has("default")) {
@ -294,7 +137,7 @@ public class YtVideoService {
results = new ArrayList<>(uniqueVideos.values()); results = new ArrayList<>(uniqueVideos.values());
if (results.isEmpty()) { if (results.isEmpty()) {
com.hlab.yanalyst.web.dto.YoutubeSearchPageDto pageDto = new com.hlab.yanalyst.web.dto.YoutubeSearchPageDto(); YoutubeSearchPageDto pageDto = new YoutubeSearchPageDto();
pageDto.setItems(results); pageDto.setItems(results);
pageDto.setNextTokens(nextTokens); pageDto.setNextTokens(nextTokens);
return pageDto; return pageDto;
@ -309,17 +152,17 @@ public class YtVideoService {
String videoApiUrl = "https://www.googleapis.com/youtube/v3/videos"; String videoApiUrl = "https://www.googleapis.com/youtube/v3/videos";
java.util.Set<String> idsToRemove = new java.util.HashSet<>(); java.util.Set<String> idsToRemove = new java.util.HashSet<>();
for (List<YoutubeSearchResultDto> batch : partitionedResults) { for (List<YoutubeSearchResultDto> batch : partitionedResults) {
String videoIds = batch.stream().map(YoutubeSearchResultDto::getVideoId).collect(Collectors.joining(",")); String videoIds = batch.stream().map(YoutubeSearchResultDto::getVideoId).collect(Collectors.joining(","));
try { try {
org.springframework.web.util.UriComponentsBuilder vBuilder = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(videoApiUrl) UriComponentsBuilder vBuilder = UriComponentsBuilder.fromHttpUrl(videoApiUrl)
.queryParam("part", "statistics,contentDetails,snippet") .queryParam("part", "statistics,contentDetails,snippet")
.queryParam("id", videoIds) .queryParam("id", videoIds)
.queryParam("key", youtubeApiKey); .queryParam("key", youtubeApiKey);
com.fasterxml.jackson.databind.JsonNode vRoot = restTemplate.getForObject(vBuilder.build().encode().toUri(), com.fasterxml.jackson.databind.JsonNode.class); JsonNode vRoot = restTemplate.getForObject(vBuilder.build().encode().toUri(), JsonNode.class);
if (vRoot != null && vRoot.has("items")) { if (vRoot != null && vRoot.has("items")) {
for (com.fasterxml.jackson.databind.JsonNode item : vRoot.get("items")) { for (JsonNode item : vRoot.get("items")) {
String vId = item.get("id").asText(); String vId = item.get("id").asText();
long viewCount = 0; long viewCount = 0;
if (item.has("statistics") && item.get("statistics").has("viewCount")) { if (item.has("statistics") && item.get("statistics").has("viewCount")) {
@ -330,14 +173,14 @@ public class YtVideoService {
String durStr = item.get("contentDetails").get("duration").asText(); String durStr = item.get("contentDetails").get("duration").asText();
durSec = (int) java.time.Duration.parse(durStr).getSeconds(); durSec = (int) java.time.Duration.parse(durStr).getSeconds();
} }
String titleForTags = ""; String titleForTags = "";
String descForTags = ""; String descForTags = "";
if (item.has("snippet")) { if (item.has("snippet")) {
if (item.get("snippet").has("title")) titleForTags = item.get("snippet").get("title").asText(); if (item.get("snippet").has("title")) titleForTags = item.get("snippet").get("title").asText();
if (item.get("snippet").has("description")) descForTags = item.get("snippet").get("description").asText(); if (item.get("snippet").has("description")) descForTags = item.get("snippet").get("description").asText();
} }
java.util.List<String> tags = new java.util.ArrayList<>(); java.util.List<String> tags = new java.util.ArrayList<>();
java.util.regex.Matcher m = java.util.regex.Pattern.compile("#[^\\s#]+").matcher(titleForTags + " " + descForTags); java.util.regex.Matcher m = java.util.regex.Pattern.compile("#[^\\s#]+").matcher(titleForTags + " " + descForTags);
while (m.find()) { while (m.find()) {
@ -346,7 +189,7 @@ public class YtVideoService {
tags.add(tag); tags.add(tag);
} }
} }
long finalViewCount = viewCount; long finalViewCount = viewCount;
int finalDurSec = durSec; int finalDurSec = durSec;
results.stream().filter(r -> r.getVideoId().equals(vId)).forEach(r -> { results.stream().filter(r -> r.getVideoId().equals(vId)).forEach(r -> {
@ -354,7 +197,7 @@ public class YtVideoService {
r.setDurationSec(finalDurSec); r.setDurationSec(finalDurSec);
r.setHashtags(tags); r.setHashtags(tags);
}); });
// Strict filter based on format (65 seconds for true Shorts) // Strict filter based on format (65 seconds for true Shorts)
boolean isShorts = "SHORTS".equalsIgnoreCase(condition.getFormat()); boolean isShorts = "SHORTS".equalsIgnoreCase(condition.getFormat());
if (isShorts && durSec > 65) { if (isShorts && durSec > 65) {
@ -368,7 +211,7 @@ public class YtVideoService {
// Ignore // Ignore
} }
} }
results.removeIf(r -> idsToRemove.contains(r.getVideoId())); results.removeIf(r -> idsToRemove.contains(r.getVideoId()));
// 3. 채널 구독자 국가 정보 가져오기 (channels API) // 3. 채널 구독자 국가 정보 가져오기 (channels API)
@ -381,39 +224,39 @@ public class YtVideoService {
String channelApiUrl = "https://www.googleapis.com/youtube/v3/channels"; String channelApiUrl = "https://www.googleapis.com/youtube/v3/channels";
java.util.Set<String> channelIdsToRemove = new java.util.HashSet<>(); java.util.Set<String> channelIdsToRemove = new java.util.HashSet<>();
for (List<String> batch : partitionedChannels) { for (List<String> batch : partitionedChannels) {
String cIdsStr = String.join(",", batch); String cIdsStr = String.join(",", batch);
try { try {
org.springframework.web.util.UriComponentsBuilder cBuilder = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(channelApiUrl) UriComponentsBuilder cBuilder = UriComponentsBuilder.fromHttpUrl(channelApiUrl)
.queryParam("part", "statistics,snippet") .queryParam("part", "statistics,snippet")
.queryParam("id", cIdsStr) .queryParam("id", cIdsStr)
.queryParam("key", youtubeApiKey); .queryParam("key", youtubeApiKey);
com.fasterxml.jackson.databind.JsonNode cRoot = restTemplate.getForObject(cBuilder.build().encode().toUri(), com.fasterxml.jackson.databind.JsonNode.class); JsonNode cRoot = restTemplate.getForObject(cBuilder.build().encode().toUri(), JsonNode.class);
if (cRoot != null && cRoot.has("items")) { if (cRoot != null && cRoot.has("items")) {
for (com.fasterxml.jackson.databind.JsonNode item : cRoot.get("items")) { for (JsonNode item : cRoot.get("items")) {
String cId = item.get("id").asText(); String cId = item.get("id").asText();
long subCount = 0; long subCount = 0;
if (item.has("statistics") && item.get("statistics").has("subscriberCount")) { if (item.has("statistics") && item.get("statistics").has("subscriberCount")) {
subCount = item.get("statistics").get("subscriberCount").asLong(); subCount = item.get("statistics").get("subscriberCount").asLong();
} }
String cCountry = "UNKNOWN"; String cCountry = "UNKNOWN";
if (item.has("snippet") && item.get("snippet").has("country")) { if (item.has("snippet") && item.get("snippet").has("country")) {
cCountry = item.get("snippet").get("country").asText(); cCountry = item.get("snippet").get("country").asText();
} }
long finalSubCount = subCount; long finalSubCount = subCount;
String finalCountry = cCountry; String finalCountry = cCountry;
results.stream().filter(r -> r.getChannelId().equals(cId)).forEach(r -> { results.stream().filter(r -> r.getChannelId().equals(cId)).forEach(r -> {
r.setSubscriberCount(finalSubCount); r.setSubscriberCount(finalSubCount);
r.setChannelCountry(finalCountry); r.setChannelCountry(finalCountry);
}); });
// 지역 기반 필터링 // 지역 기반 필터링
boolean isSearchEmpty = !StringUtils.hasText(condition.getKeyword()); boolean isSearchEmpty = !StringUtils.hasText(condition.getKeyword());
boolean keep = false; boolean keep = false;
if (isChannelSearch) { if (isChannelSearch) {
keep = true; keep = true;
} else if (!"UNKNOWN".equals(cCountry)) { } else if (!"UNKNOWN".equals(cCountry)) {
@ -435,11 +278,11 @@ public class YtVideoService {
break; break;
} }
} }
boolean matched = false; boolean matched = false;
if (regions.contains("KR") && title.matches(".*[가-힣].*")) matched = true; if (regions.contains("KR") && title.matches(".*[가-힣].*")) matched = true;
if (regions.contains("JP") && title.matches(".*[ぁ-んァ-ン一-龥].*")) matched = true; if (regions.contains("JP") && title.matches(".*[ぁ-んァ-ン一-龥].*")) matched = true;
if (!matched && (regions.contains("US") || regions.isEmpty())) { if (!matched && (regions.contains("US") || regions.isEmpty())) {
// US is selected (or no region selected). Allow if it DOES NOT contain obvious non-English scripts. // US is selected (or no region selected). Allow if it DOES NOT contain obvious non-English scripts.
boolean hasForeign = false; boolean hasForeign = false;
@ -450,16 +293,16 @@ public class YtVideoService {
} }
if (!regions.contains("KR") && title.matches(".*[가-힣].*")) hasForeign = true; if (!regions.contains("KR") && title.matches(".*[가-힣].*")) hasForeign = true;
if (!regions.contains("JP") && title.matches(".*[ぁ-んァ-ン一-龥].*")) hasForeign = true; if (!regions.contains("JP") && title.matches(".*[ぁ-んァ-ン一-龥].*")) hasForeign = true;
if (!hasForeign) { if (!hasForeign) {
matched = true; matched = true;
} }
} }
keep = matched; keep = matched;
} }
} }
if (!keep) { if (!keep) {
channelIdsToRemove.add(cId); channelIdsToRemove.add(cId);
} }
@ -469,19 +312,18 @@ public class YtVideoService {
// Ignore // Ignore
} }
} }
results.removeIf(r -> channelIdsToRemove.contains(r.getChannelId())); results.removeIf(r -> channelIdsToRemove.contains(r.getChannelId()));
// 조회수 내림차순 정렬 // 조회수 내림차순 정렬
results.sort((v1, v2) -> Long.compare( results.sort((v1, v2) -> Long.compare(
v2.getViewCount() != null ? v2.getViewCount() : 0L, v2.getViewCount() != null ? v2.getViewCount() : 0L,
v1.getViewCount() != null ? v1.getViewCount() : 0L v1.getViewCount() != null ? v1.getViewCount() : 0L
)); ));
com.hlab.yanalyst.web.dto.YoutubeSearchPageDto pageDto = new com.hlab.yanalyst.web.dto.YoutubeSearchPageDto(); YoutubeSearchPageDto pageDto = new YoutubeSearchPageDto();
pageDto.setItems(results); pageDto.setItems(results);
pageDto.setNextTokens(nextTokens); pageDto.setNextTokens(nextTokens);
return pageDto; return pageDto;
} }
} }

View File

@ -1,14 +0,0 @@
package com.hlab.yanalyst.service.external;
import org.springframework.stereotype.Service;
import com.hlab.yanalyst.domain.opal.dto.OpalDraftResponseDto;
import java.util.Map;
public interface ExternalApiService {
String generateScript(String videoUrl);
OpalDraftResponseDto generateOpalDraft(String script, String feedback, String mode);
String fetchFinalScript();
String fetchOpeningScript(String docId);
Map<String, Object> generateFinalAsset(String finalScript);
}

View File

@ -1,262 +0,0 @@
package com.hlab.yanalyst.service.external;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Primary;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp;
import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.util.store.FileDataStoreFactory;
import com.google.api.services.docs.v1.Docs;
import com.google.api.services.docs.v1.DocsScopes;
import com.google.api.services.docs.v1.model.*;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@Primary
@RequiredArgsConstructor
public class ExternalApiServiceImpl implements ExternalApiService {
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@Override
public String generateScript(String videoUrl) {
String apiUrl = "http://h-python.tolag.shop/transcript";
log.info("Requesting transcript for URL: {}", videoUrl);
try {
Map<String, String> requestBody = Collections.singletonMap("url", videoUrl);
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, requestBody, String.class);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
ScriptResponseDto scriptDto = objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
return scriptDto.getTranscript();
} else {
log.error("Failed to fetch script from Python API. Status: {}", response.getStatusCode());
return generateFallbackScript(videoUrl, "API returned status: " + response.getStatusCode());
}
} catch (Exception e) {
log.error("Error calling Python API", e);
// Fallback to prevent blocking the workflow
return generateFallbackScript(videoUrl, e.getMessage());
}
}
private String generateFallbackScript(String url, String errorMsg) {
return String.format("""
[SYSTEM NOTICE: Failed to retrieve real transcript due to external API error.]
[Error Detail: %s]
[Fallback Script for %s]
00:00 - Introduction
Hello, this is a placeholder transcript generated by the system because the external Python API (YouTube Transcript) is currently blocked or unavailable.
00:15 - Main Topic
Usually, real content would appear here. Since YouTube blocks known cloud IPs, this is a common issue with scraping services.
00:30 - Conclusion
You can proceed to generate Opal Drafts and Final Assets using this placeholder text to test the rest of the intended workflow.
""", errorMsg, url);
}
@Override
public com.hlab.yanalyst.domain.opal.dto.OpalDraftResponseDto generateOpalDraft(String script, String feedback, String mode) {
// Determine Doc ID based on mode
String docId;
if ("STRUCTURE_CHANGE".equals(mode)) {
docId = "11eFwYXm1Ld2vZUrOHvJZEmN69KDXvQygEysdXWr8-W4";
} else {
// "TRUE_STORY" (Default)
docId = "1EHpn1GNregte6jTs43ycUUVqy6oljIPj6dm-uIXkcZc";
}
log.info("Fetching Opal draft (summary) using Google Docs API from Doc ID: {} (Mode: {})", docId, mode);
try {
// 1. Fetch content using API
String text = readGoogleDoc(docId);
String oldSummaryMarker = "old_script_summary";
String newSummaryMarker = "new_script_summary";
int oldStart = text.indexOf(oldSummaryMarker);
int newStart = text.indexOf(newSummaryMarker);
String oldSum = "";
String newSum = "";
if (oldStart != -1 && newStart != -1) {
oldSum = text.substring(oldStart + oldSummaryMarker.length(), newStart).trim();
newSum = text.substring(newStart + newSummaryMarker.length()).trim();
// Clear the doc if valid
clearGoogleDoc(docId);
log.info("Cleared content of Google Doc: {}", docId);
} else {
// Fallback if markers missing
log.warn("Markers not found in doc. NOT clearing doc.");
oldSum = "Markers not found in doc.";
newSum = text.substring(0, Math.min(text.length(), 200)) + "...";
}
if (feedback != null && !feedback.isEmpty()) {
newSum += "\n\n[Reflecting Feedback: " + feedback + "]";
}
return new com.hlab.yanalyst.domain.opal.dto.OpalDraftResponseDto(oldSum, newSum);
} catch (Exception e) {
log.error("Error fetching Opal draft from GDoc", e);
throw new RuntimeException("Error fetching Opal draft", e);
}
}
@Override
public String fetchFinalScript() {
// String docId = "1tThnN2-OdYS-RuAWUWFsW9W238TPqZssaww5_wULOg4";
String docId = "1jiSEwFuWeggIFln08j15pXw-8iUsFaDVjVKl3RbVdsw";
log.info("Fetching final script using Google Docs API from Doc ID: {}", docId);
try {
String text = readGoogleDoc(docId);
// Clear content
clearGoogleDoc(docId);
log.info("Cleared content of Google Doc: {}", docId);
return text;
} catch (Exception e) {
log.error("Error fetching final script", e);
throw new RuntimeException("Error fetching final script", e);
}
}
@Override
public String fetchOpeningScript(String docId) {
log.info("Fetching opening script using Google Docs API from Doc ID: {}", docId);
try {
String text = readGoogleDoc(docId);
// Clear content
clearGoogleDoc(docId);
log.info("Cleared content of Google Doc: {}", docId);
return text;
} catch (Exception e) {
log.error("Error fetching opening script", e);
throw new RuntimeException("Error fetching opening script", e);
}
}
// --- Google Docs Helper Methods ---
private static final String CREDENTIALS_FILE_PATH = "/credentials.json";
private static final java.util.List<String> SCOPES = Collections.singletonList(DocsScopes.DOCUMENTS);
private static final String TOKENS_DIRECTORY_PATH = "tokens";
private Docs getDocsService() throws Exception {
final NetHttpTransport HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport();
GsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
InputStream in = ExternalApiServiceImpl.class.getResourceAsStream(CREDENTIALS_FILE_PATH);
if (in == null) {
throw new FileNotFoundException("Resource not found: " + CREDENTIALS_FILE_PATH);
}
GoogleClientSecrets clientSecrets = GoogleClientSecrets.load(JSON_FACTORY, new InputStreamReader(in));
// Build flow and trigger user authorization request.
GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT, JSON_FACTORY, clientSecrets, SCOPES)
.setDataStoreFactory(new FileDataStoreFactory(new java.io.File(TOKENS_DIRECTORY_PATH)))
.setAccessType("offline")
.build();
LocalServerReceiver receiver = new LocalServerReceiver.Builder().setPort(8888).build();
// This authorize call will open browser if token is missing
Credential credential = new AuthorizationCodeInstalledApp(flow, receiver).authorize("user");
return new Docs.Builder(HTTP_TRANSPORT, JSON_FACTORY, credential)
.setApplicationName("HLAB-Backend")
.build();
}
private String readGoogleDoc(String docId) throws Exception {
Docs service = getDocsService();
Document doc = service.documents().get(docId).execute();
StringBuilder sb = new StringBuilder();
doc.getBody().getContent().forEach(c -> {
if (c.getParagraph() != null) {
c.getParagraph().getElements().forEach(e -> {
if (e.getTextRun() != null) {
sb.append(e.getTextRun().getContent());
}
});
sb.append("\n"); // Add line break for paragraphs
}
});
return sb.toString();
}
private void clearGoogleDoc(String docId) throws Exception {
Docs service = getDocsService();
Document doc = service.documents().get(docId).execute();
// Calculate the end index. Content usually ends with a newline.
int lastContentIndex = doc.getBody().getContent().size() - 1;
int docEnd = doc.getBody().getContent().get(lastContentIndex).getEndIndex();
log.info("Attempting to clear doc {}. Last content index: {}, Doc End Index: {}", docId, lastContentIndex, docEnd);
// Safety check: if doc is already practically empty
if (docEnd > 2) {
int deleteEndIndex = docEnd - 1;
log.info("Deleting range: 1 to {}", deleteEndIndex);
Request request = new Request()
.setDeleteContentRange(new DeleteContentRangeRequest()
.setRange(new Range().setStartIndex(1).setEndIndex(deleteEndIndex)));
BatchUpdateDocumentRequest body = new BatchUpdateDocumentRequest().setRequests(Collections.singletonList(request));
BatchUpdateDocumentResponse response = service.documents().batchUpdate(docId, body).execute();
log.info("Clear doc response: {}", response);
} else {
log.info("Doc appears empty or too small to clear (endIndex <= 2). Skipping delete.");
}
}
@Override
public Map<String, Object> generateFinalAsset(String finalScript) {
// Stub for now
try { Thread.sleep(2000); } catch (InterruptedException e) {}
Map<String, Object> asset = new HashMap<>();
asset.put("title", "Generated Title (Real)");
asset.put("summary", "Generated Summary (Real)");
asset.put("timeline", List.of(Map.of("time", "00:00", "desc", "Intro"), Map.of("time", "01:00", "desc", "Main Content")));
asset.put("video_prompt", "Cinematic prompt for " + finalScript.substring(0, 10));
asset.put("image_urls", List.of("https://via.placeholder.com/150", "https://via.placeholder.com/150"));
return asset;
}
}

View File

@ -1,51 +0,0 @@
package com.hlab.yanalyst.service.external;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.List;
@Service
public class ExternalApiServiceStub implements ExternalApiService {
@Override
public String generateScript(String videoUrl) {
// Stub: simulate python script generation
try { Thread.sleep(1000); } catch (InterruptedException e) {}
return "Stub Script Content for " + videoUrl + "\n\n[Intro]\nHello everyone, today we are analyzing...";
}
@Override
public com.hlab.yanalyst.domain.opal.dto.OpalDraftResponseDto generateOpalDraft(String script, String feedback, String mode) {
// Stub: simulate opal generation
try { Thread.sleep(1500); } catch (InterruptedException e) {}
String prefix = feedback != null ? "[Reflecting Feedback: " + feedback + "]\n" : "";
return new com.hlab.yanalyst.domain.opal.dto.OpalDraftResponseDto(
"Stub Old Summary from " + script.substring(0, Math.min(script.length(), 20)),
prefix + "Stub New Summary: " + script.substring(0, Math.min(script.length(), 20))
);
}
@Override
public String fetchFinalScript() {
return "Stub Final Script Content";
}
@Override
public String fetchOpeningScript(String docId) {
return "Stub Opening Script Content from " + docId;
}
@Override
public Map<String, Object> generateFinalAsset(String finalScript) {
// Stub: simulate final asset generation
try { Thread.sleep(2000); } catch (InterruptedException e) {}
Map<String, Object> asset = new HashMap<>();
asset.put("title", "Generated Title");
asset.put("summary", "Generated Summary");
asset.put("timeline", List.of(Map.of("time", "00:00", "desc", "Intro"), Map.of("time", "01:00", "desc", "Main Topic")));
asset.put("video_prompt", "A cinematic shot of...");
asset.put("image_urls", List.of("http://example.com/img1.jpg", "http://example.com/img2.jpg"));
return asset;
}
}

View File

@ -1,81 +0,0 @@
package com.hlab.yanalyst.web;
import com.hlab.yanalyst.domain.opal.OpalDraft;
import com.hlab.yanalyst.domain.opal.OpalFinal;
import com.hlab.yanalyst.domain.opal.OpalFinalAsset;
import com.hlab.yanalyst.domain.script.ScriptGen;
import com.hlab.yanalyst.global.common.ApiResponse;
import com.hlab.yanalyst.service.AnalysisWorkflowService;
import com.hlab.yanalyst.web.dto.DraftGenerateRequest;
import com.hlab.yanalyst.web.dto.FinalAssetResponse;
import com.hlab.yanalyst.web.dto.OpalDraftResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/videos")
@RequiredArgsConstructor
public class VideoActionController {
private final AnalysisWorkflowService workflowService;
@PostMapping("/{videoId}/script")
public ApiResponse<Void> generateScript(@PathVariable Long videoId) {
workflowService.generateScript(videoId);
return ApiResponse.ok(null);
}
@PostMapping("/{videoId}/drafts")
public ApiResponse<OpalDraftResponse> generateDraft(
@PathVariable Long videoId,
@RequestBody(required = false) DraftGenerateRequest request) {
String feedback = request != null ? request.getFeedback() : null;
String mode = request != null ? request.getMode() : null;
OpalDraft draft = workflowService.generateDraft(videoId, feedback, mode);
return ApiResponse.created(new OpalDraftResponse(draft));
}
@PostMapping("/{videoId}/drafts/{draftId}/accept")
public ApiResponse<Void> acceptDraft(
@PathVariable Long videoId,
@PathVariable Long draftId) {
workflowService.acceptDraft(videoId, draftId);
return ApiResponse.ok(null);
}
@PostMapping("/{videoId}/final-asset")
public ApiResponse<FinalAssetResponse> generateFinalAsset(@PathVariable Long videoId) {
OpalFinalAsset asset = workflowService.generateFinalAsset(videoId);
// Need to refetch opalFinal to pass to DTO, strictly strictly speaking asset has reference
return ApiResponse.created(new FinalAssetResponse(asset.getOpalFinal(), asset));
}
@PostMapping("/{videoId}/complete")
public ApiResponse<Boolean> toggleComplete(@PathVariable Long videoId) {
boolean isCompleted = workflowService.toggleComplete(videoId);
return ApiResponse.ok(isCompleted);
}
@PutMapping("/{videoId}/final-asset")
public ApiResponse<Void> updateFinalAsset(
@PathVariable Long videoId,
@RequestBody java.util.Map<String, String> body) {
String newText = body.get("finalScriptText");
workflowService.updateFinalAsset(videoId, newText);
return ApiResponse.ok(null);
}
@PostMapping("/{videoId}/opening")
public ApiResponse<Void> generateOpening(
@PathVariable Long videoId,
@RequestBody java.util.Map<String, String> body) {
String docId = body.get("docId");
workflowService.generateOpening(videoId, docId);
return ApiResponse.ok(null);
}
@DeleteMapping("/{videoId}/opening")
public ApiResponse<Void> resetOpening(@PathVariable Long videoId) {
workflowService.resetOpening(videoId);
return ApiResponse.ok(null);
}
}

View File

@ -7,12 +7,10 @@ import org.springframework.web.bind.annotation.GetMapping;
@Controller @Controller
public class WebController { public class WebController {
private final com.hlab.yanalyst.service.YtVideoService ytVideoService;
private final com.hlab.yanalyst.domain.production.ProductionService productionService; private final com.hlab.yanalyst.domain.production.ProductionService productionService;
private final com.hlab.yanalyst.domain.channel.ChannelService channelService; private final com.hlab.yanalyst.domain.channel.ChannelService channelService;
public WebController(com.hlab.yanalyst.service.YtVideoService ytVideoService, com.hlab.yanalyst.domain.production.ProductionService productionService, com.hlab.yanalyst.domain.channel.ChannelService channelService) { public WebController(com.hlab.yanalyst.domain.production.ProductionService productionService, com.hlab.yanalyst.domain.channel.ChannelService channelService) {
this.ytVideoService = ytVideoService;
this.productionService = productionService; this.productionService = productionService;
this.channelService = channelService; this.channelService = channelService;
} }
@ -36,18 +34,6 @@ public class WebController {
return "videos"; return "videos";
} }
@GetMapping("/videos/{videoId}")
public String videoDetail(@org.springframework.web.bind.annotation.PathVariable Long videoId, Model model) {
model.addAttribute("currentPage", "videos");
model.addAttribute("video", ytVideoService.getVideoDetail(videoId));
// Also fetch drafts and final asset initially to render server-side if needed,
// or let the view fetch via AJAX.
// For 'Draft History' and 'Final', let's pass them to model for initial load to avoid layout shift.
model.addAttribute("drafts", ytVideoService.getDrafts(videoId));
model.addAttribute("finalAsset", ytVideoService.getFinalAsset(videoId));
return "video_detail";
}
@GetMapping("/collection") @GetMapping("/collection")
public String collection(Model model) { public String collection(Model model) {
model.addAttribute("currentPage", "collection"); model.addAttribute("currentPage", "collection");

View File

@ -2,7 +2,7 @@ package com.hlab.yanalyst.web;
import com.hlab.yanalyst.domain.channel.SearchCollectionService; import com.hlab.yanalyst.domain.channel.SearchCollectionService;
import com.hlab.yanalyst.global.common.ApiResponse; import com.hlab.yanalyst.global.common.ApiResponse;
import com.hlab.yanalyst.service.YtVideoService; import com.hlab.yanalyst.service.YoutubeSearchService;
import com.hlab.yanalyst.web.dto.YoutubeSearchCondition; import com.hlab.yanalyst.web.dto.YoutubeSearchCondition;
import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto; import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@ -19,12 +19,12 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class YoutubeSearchApiController { public class YoutubeSearchApiController {
private final YtVideoService ytVideoService; private final YoutubeSearchService youtubeSearchService;
private final SearchCollectionService searchCollectionService; private final SearchCollectionService searchCollectionService;
@PostMapping("/search") @PostMapping("/search")
public com.hlab.yanalyst.web.dto.YoutubeSearchPageDto search(@RequestBody YoutubeSearchCondition condition) { public com.hlab.yanalyst.web.dto.YoutubeSearchPageDto search(@RequestBody YoutubeSearchCondition condition) {
return ytVideoService.searchYoutubeVideos(condition); return youtubeSearchService.searchYoutubeVideos(condition);
} }
@PostMapping("/collect") @PostMapping("/collect")

View File

@ -1,62 +0,0 @@
package com.hlab.yanalyst.web;
import com.hlab.yanalyst.global.common.ApiResponse;
import com.hlab.yanalyst.service.YtVideoService;
import com.hlab.yanalyst.web.dto.FinalAssetResponse;
import com.hlab.yanalyst.web.dto.OpalDraftResponse;
import com.hlab.yanalyst.web.dto.VideoDetailResponse;
import com.hlab.yanalyst.web.dto.VideoResponse;
import com.hlab.yanalyst.web.dto.VideoSearchCondition;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/videos")
@RequiredArgsConstructor
public class YtVideoController {
private final YtVideoService ytVideoService;
@org.springframework.web.bind.annotation.PostMapping
public ApiResponse<?> addVideo(@org.springframework.web.bind.annotation.RequestBody com.hlab.yanalyst.web.dto.VideoAddRequest request) {
try {
ytVideoService.saveVideoFromUrl(request.getUrl());
return ApiResponse.ok("Video added successfully");
} catch (IllegalArgumentException e) {
return ApiResponse.error(e.getMessage());
} catch (Exception e) {
return ApiResponse.error("Failed to add video: " + e.getMessage());
}
}
@GetMapping
public ApiResponse<Page<VideoResponse>> getVideos(
VideoSearchCondition condition,
@PageableDefault(size = 20, sort = "lastCrawledAt", direction = Sort.Direction.DESC) Pageable pageable) {
return ApiResponse.ok(ytVideoService.getVideos(condition, pageable));
}
@GetMapping("/{videoId}")
public ApiResponse<VideoDetailResponse> getVideoDetail(@PathVariable Long videoId) {
return ApiResponse.ok(ytVideoService.getVideoDetail(videoId));
}
@GetMapping("/{videoId}/drafts")
public ApiResponse<List<OpalDraftResponse>> getDrafts(@PathVariable Long videoId) {
return ApiResponse.ok(ytVideoService.getDrafts(videoId));
}
@GetMapping("/{videoId}/final-asset")
public ApiResponse<FinalAssetResponse> getFinalAsset(@PathVariable Long videoId) {
return ApiResponse.ok(ytVideoService.getFinalAsset(videoId));
}
}

View File

@ -1,9 +0,0 @@
package com.hlab.yanalyst.web.dto;
import lombok.Data;
@Data
public class DraftGenerateRequest {
private String feedback;
private String mode; // "TRUE_STORY" or "STRUCTURE_CHANGE"
}

View File

@ -1,22 +0,0 @@
package com.hlab.yanalyst.web.dto;
import com.hlab.yanalyst.domain.opal.OpalFinal;
import com.hlab.yanalyst.domain.opal.OpalFinalAsset;
import lombok.Data;
@Data
public class FinalAssetResponse {
private Long finalId;
private String finalScriptText;
private String assetJson;
private Boolean isActive;
public FinalAssetResponse(OpalFinal opalFinal, OpalFinalAsset asset) {
this.finalId = opalFinal.getFinalId();
this.finalScriptText = opalFinal.getFinalScriptText();
this.isActive = opalFinal.getIsActive();
if (asset != null) {
this.assetJson = asset.getAssetJson();
}
}
}

View File

@ -1,30 +0,0 @@
package com.hlab.yanalyst.web.dto;
import com.hlab.yanalyst.domain.opal.OpalDraft;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class OpalDraftResponse {
private Long draftId;
private Integer versionNo;
private String responseText;
private String userFeedback;
private Boolean isAccepted;
private LocalDateTime createdAt;
private String oldScriptSummary;
private String newScriptSummary;
public OpalDraftResponse(OpalDraft draft) {
this.draftId = draft.getDraftId();
this.versionNo = draft.getVersionNo();
this.responseText = draft.getResponseText();
this.userFeedback = draft.getUserFeedback();
this.isAccepted = draft.getIsAccepted();
this.createdAt = draft.getCreatedAt();
this.oldScriptSummary = draft.getOldScriptSummary();
this.newScriptSummary = draft.getNewScriptSummary();
}
}

View File

@ -1,8 +0,0 @@
package com.hlab.yanalyst.web.dto;
import lombok.Data;
@Data
public class VideoAddRequest {
private String url;
}

View File

@ -1,36 +0,0 @@
package com.hlab.yanalyst.web.dto;
import com.hlab.yanalyst.domain.video.YtVideo;
import lombok.Data;
@Data
public class VideoDetailResponse {
private VideoResponse metadata;
private String videoUrl;
private String youtubeVideoId;
// We will load Script, Draft, Final via separate API or include them here?
// Requirement says "Center Area: 3 Tabs".
// It's better to fetch detail data including status, but maybe load heavy text data lazy or in this response.
// Given the requirement "Show script_gen.response_text", it might be large.
// Let's include everything for simplicity as per common SPA patterns unless it's huge.
private String scriptContent; // ScriptGen.responseText
private String scriptStatus;
private String openingScript;
// We can return list of drafts separately or here.
// Let's create a separate API for drafts history to keep this clean,
// or just include basic info.
// Requirement: "Opal Draft History - list newest first".
// Let's fetch the detailed lists via separate calls to keep payload light and responsive.
// But for "Script" tab, if it's 1:1, we can include it.
public VideoDetailResponse(YtVideo video) {
this.metadata = new VideoResponse(video);
this.videoUrl = video.getVideoUrl();
this.youtubeVideoId = video.getYoutubeVideoId();
this.openingScript = video.getOpeningScript();
}
}

View File

@ -1,36 +0,0 @@
package com.hlab.yanalyst.web.dto;
import com.hlab.yanalyst.domain.video.YtVideo;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class VideoResponse {
private Long videoId;
private String thumbnailUrl;
private String title;
private String channelTitle;
private Long viewCount;
private Long subscriberCount;
private BigDecimal viewsPerSubRatio;
private BigDecimal viewsPerHour;
private String status;
private LocalDateTime lastCrawledAt;
private Boolean isCompleted;
public VideoResponse(YtVideo video) {
this.videoId = video.getVideoId();
this.thumbnailUrl = video.getThumbnailUrl();
this.title = video.getTitle();
this.channelTitle = video.getChannelTitle();
this.viewCount = video.getViewCount();
this.subscriberCount = video.getSubscriberCount();
this.viewsPerSubRatio = video.getViewsPerSubRatio();
this.viewsPerHour = video.getViewsPerHour();
this.status = video.getStatus();
this.lastCrawledAt = video.getLastCrawledAt();
this.isCompleted = video.getIsCompleted() != null && video.getIsCompleted();
}
}

View File

@ -1,18 +0,0 @@
package com.hlab.yanalyst.web.dto;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
@Data
public class VideoSearchCondition {
private String status;
private String keyword; // title or channelTitle
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate startDate;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate endDate;
}

View File

@ -1,622 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/base}">
<body>
<div layout:fragment="content">
<!-- Breadcrumb / Back -->
<div class="mb-4">
<a th:href="@{/videos}" class="text-sm text-muted hover:text-primary flex items-center gap-1">
<i data-lucide="arrow-left" style="width: 16px;"></i> Back to List
</a>
</div>
<!-- Video Metadata Header -->
<div class="card mb-6 flex gap-6 mobile-col">
<img th:src="${video.metadata.thumbnailUrl}" alt="Thumb" class="video-header-image"
style="width: 240px; height: 135px; object-fit: cover; border-radius: var(--radius-md);">
<div class="flex-1 mobile-w-full">
<div class="flex justify-between items-start mobile-col mobile-gap-4">
<div>
<h1 class="text-xl font-bold mb-1" th:text="${video.metadata.title}">Video Title</h1>
<a href="#" class="text-primary text-sm hover:underline"
th:text="${video.metadata.channelTitle}">Channel Name</a>
</div>
<div class="flex gap-2">
<button id="btn-toggle-complete" class="btn btn-ghost" onclick="toggleComplete()"
th:classappend="${video.metadata.isCompleted == true} ? 'bg-success/20 text-success border-success/50' : ''"
style="border: 1px solid var(--glass-border);">
<span th:if="${video.metadata.isCompleted == true}"><i data-lucide="check-circle"
style="width: 16px; margin-right: 6px;"></i> Completed</span>
<span th:unless="${video.metadata.isCompleted == true}"><i data-lucide="circle"
style="width: 16px; margin-right: 6px;"></i> Mark Complete</span>
</button>
<a th:href="${video.videoUrl}" target="_blank" class="btn btn-ghost"
style="border: 1px solid var(--glass-border);">
Open YouTube <i data-lucide="external-link" style="width: 16px; margin-left: 6px;"></i>
</a>
</div>
</div>
<div class="flex gap-6 mt-4 text-sm text-muted stats-grid mobile-gap-4">
<div>
<span class="block font-bold text-white" th:text="${video.metadata.viewCount}">10K</span>
Views
</div>
<div>
<span class="block font-bold text-white" th:text="${video.metadata.subscriberCount}">5K</span>
Subs
</div>
<div>
<span class="block font-bold text-white" th:text="${video.metadata.viewsPerHour}">120</span>
Views/Hr
</div>
<div>
<span class="block font-bold text-white" th:text="${video.metadata.status}">CRAWLED</span>
Status
</div>
</div>
</div>
</div>
<!-- Custom Tabs Navigation (Block Style) -->
<div class="flex justify-start mb-4 border-b border-white/10 pb-1">
<div class="inline-flex overflow-hidden rounded-md border border-white/10 p-1"
style="background: rgba(0,0,0,0.3);">
<button class="tab-item px-6 py-2 text-sm font-bold transition-all rounded-sm hover:text-white"
onclick="switchTab('script')" id="btn-tab-script">
Python Script
</button>
<div style="width: 1px; background: rgba(255,255,255,0.1); margin: 6px 0;"></div>
<button class="tab-item px-6 py-2 text-sm font-bold transition-all rounded-sm hover:text-white"
onclick="switchTab('draft')" id="btn-tab-draft">
Opal Drafts
</button>
<div style="width: 1px; background: rgba(255,255,255,0.1); margin: 6px 0;"></div>
<button class="tab-item px-6 py-2 text-sm font-bold transition-all rounded-sm hover:text-white"
onclick="switchTab('final')" id="btn-tab-final">
Final Output
</button>
<div style="width: 1px; background: rgba(255,255,255,0.1); margin: 6px 0;"></div>
<button class="tab-item px-6 py-2 text-sm font-bold transition-all rounded-sm hover:text-white"
onclick="switchTab('opening')" id="btn-tab-opening">
오프닝생성
</button>
</div>
</div>
<!-- Tab Contents -->
<div style="min-height: 400px;">
<!-- 1. Script Tab -->
<div id="tab-script" class="tab-content">
<div class="flex justify-between items-center mb-4 mobile-col mobile-items-start mobile-gap-4">
<h3 class="font-bold text-lg">Python Script (Transcript)</h3>
<button id="btn-gen-script" class="btn btn-primary mobile-w-full" onclick="generateScript()">
<i data-lucide="file-code" style="width: 18px; margin-right: 6px;"></i> Generate Script
</button>
</div>
<div class="card" style="background: #1e1e1e; min-height: 300px;">
<div style="display: flex; justify-content: flex-end; margin-bottom: 8px;">
<button onclick="copyToClipboard('script-text')"
class="text-xs text-muted hover:text-white flex items-center gap-1">
<i data-lucide="copy" style="width: 12px;"></i> Copy
</button>
</div>
<pre id="script-text" class="text-sm font-mono text-gray-300"
style="white-space: pre-wrap; word-break: break-all;"
th:text="${video.scriptContent} ?: 'No script generated yet.'"></pre>
</div>
</div>
<!-- 2. Drafts Tab -->
<div id="tab-draft" class="tab-content hidden">
<div class="flex justify-between items-start mb-6 mobile-col mobile-gap-4">
<h3 class="font-bold text-lg">Opal Draft History</h3>
<div class="flex flex-col gap-3 mobile-w-full" style="min-width: 300px;">
<!-- Mode Selection -->
<div class="flex gap-4 p-3 bg-black/20 rounded-lg border border-white/10">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="draftMode" value="TRUE_STORY" checked
class="text-primary focus:ring-primary bg-gray-800 border-gray-600">
<span class="text-sm font-bold text-white">이것은 실화다 (Default)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="draftMode" value="STRUCTURE_CHANGE"
class="text-primary focus:ring-primary bg-gray-800 border-gray-600">
<span class="text-sm font-bold text-white">구조변경</span>
</label>
</div>
<!-- Input and Button -->
<div class="flex gap-2">
<input type="text" id="draft-feedback" placeholder="AI에게 전달할 요청사항 (선택)..."
class="p-2 text-sm flex-1"
style="background: rgba(255,255,255,0.05); border: 1px solid var(--glass-border); border-radius: var(--radius-md); color: white;">
<button class="btn btn-primary whitespace-nowrap" onclick="generateDraft()">
<i data-lucide="sparkles" style="width: 18px; margin-right: 6px;"></i> Generate Draft
</button>
</div>
</div>
</div> <!-- Close header flex row -->
<div class="flex flex-col gap-6">
<div th:each="draft : ${drafts}" class="card p-0 overflow-hidden"
style="border: 1px solid rgba(255,255,255,0.1);">
<!-- Draft Header -->
<div class="p-4 bg-white/5 border-b border-white/10 flex justify-between items-center">
<div>
<span class="font-bold text-lg">Version #<span
th:text="${draft.versionNo}">1</span></span>
<span class="text-muted text-sm ml-2"
th:text="${#temporals.format(draft.createdAt, 'yyyy-MM-dd HH:mm')}">Time</span>
<span th:if="${draft.isAccepted}"
class="text-success text-sm font-bold ml-2">[ACCEPTED]</span>
</div>
<div class="flex gap-2">
<!-- Removed Copy Button from here -->
<button th:if="${!draft.isAccepted}"
class="btn btn-sm btn-ghost border border-white/20 hover:bg-white/10"
th:onclick="'acceptDraft(' + ${draft.draftId} + ')'">
Accept This Version
</button>
</div>
</div>
<div class="p-4">
<p th:if="${draft.userFeedback}"
class="text-sm text-muted mb-4 p-2 bg-yellow-500/10 border border-yellow-500/20 rounded">
<span class="text-yellow-500 font-bold">Feedback:</span> <span
th:text="${draft.userFeedback}"></span>
</p>
<!-- Old Summary (Original) -->
<div th:if="${draft.oldScriptSummary}" class="p-4 rounded-lg mb-4"
style="background-color: rgba(22,22,30,0.5); border: 1px solid rgba(255,255,255,0.05);">
<div class="flex justify-between items-center mb-2">
<h4 style="font-size: 14px; font-weight: bold; color: #facc15; margin: 0;">
Original Summary
</h4>
<button class="text-xs text-muted hover:text-white flex items-center gap-1"
th:onclick="'copyToClipboard(\'old-summary-' + ${draft.draftId} + '\')'">
<i data-lucide="copy" style="width: 12px;"></i> Copy
</button>
</div>
<div th:id="'old-summary-' + ${draft.draftId}"
style="font-size: 16px; line-height: 1.8; color: #9ca3af; white-space: pre-wrap;"
th:text="${draft.oldScriptSummary}"></div>
</div>
<!-- Single View for Main Content (New Summary) -->
<div class="p-4 rounded-lg"
style="background-color: #16161e; border: 1px solid rgba(255,255,255,0.1);">
<div class="flex justify-between items-center mb-2">
<h4 style="font-size: 14px; font-weight: bold; color: #34d399; margin: 0;">
Generated Content
</h4>
<button class="text-xs text-muted hover:text-white flex items-center gap-1"
th:onclick="'copyToClipboard(\'draft-content-' + ${draft.draftId} + '\')'">
<i data-lucide="copy" style="width: 12px;"></i> Copy
</button>
</div>
<div th:id="'draft-content-' + ${draft.draftId}"
style="font-size: 18px; line-height: 1.8; color: #e5e7eb; white-space: pre-wrap;"
th:text="${draft.newScriptSummary ?: draft.responseText}"></div>
</div>
</div>
</div>
<div th:if="${drafts.isEmpty()}"
class="text-center text-muted py-12 bg-white/5 rounded-lg border border-dashed border-white/20">
<i data-lucide="wind" class="mx-auto mb-2 text-muted" style="width: 32px; height: 32px;"></i>
No drafts generated yet.
</div>
</div>
</div>
<!-- 3. Final Output Tab (Redesigned) -->
<div id="tab-final" class="tab-content hidden">
<!-- Top Controls -->
<div class="flex justify-between items-center mb-6 mobile-col mobile-items-start mobile-gap-4">
<h3 class="font-bold text-lg">Final Asset Output</h3>
<div class="flex gap-2">
<button class="btn btn-primary" onclick="generateFinalAsset()">
<i data-lucide="refresh-cw" style="width: 18px; margin-right: 6px;"></i> Regenerate
Asset
</button>
</div>
</div>
<div th:if="${finalAsset == null}"
class="text-center text-muted py-12 bg-white/5 rounded-lg border border-dashed border-white/20">
<i data-lucide="lock" class="mx-auto mb-2 text-muted" style="width: 32px; height: 32px;"></i>
No finalized asset available. <br>Please accept a draft version first to generate the final
output.
</div>
<!-- Final Content Viewer (Production Style) -->
<div th:if="${finalAsset != null}"
style="width: 100%; height: 700px; background-color: #1a1b26; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1); display: flex; flex-direction: column; overflow: hidden;">
<!-- Header -->
<div
style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background-color: #1a1b26; border-bottom: 1px solid rgba(255,255,255,0.1);">
<h3
style="font-size: 18px; font-weight: bold; color: white; margin: 0; display: flex; align-items: center; gap: 8px;">
<i data-lucide="file-check" style="color: #22d3ee; width: 20px; height: 20px;"></i>
Final Script
</h3>
<div style="display: flex; align-items: center; gap: 8px;">
<button onclick="copyFinalText()"
style="background: none; border: none; color: #888; cursor: pointer; padding: 4px;"
title="Copy to Clipboard">
<i data-lucide="copy" style="width: 20px; height: 20px;"></i>
</button>
</div>
</div>
<!-- Content -->
<div
style="flex: 1; padding: 24px; overflow-y: hidden; background-color: #16161e; display: flex; flex-direction: column;">
<textarea id="final-asset-content"
style="width: 100%; flex: 1; font-size: 18px; line-height: 1.8; color: #e5e7eb; background: transparent; border: none; resize: none; outline: none; font-family: inherit;"
th:text="${finalAsset.finalScriptText}"></textarea>
<!-- Optional JSON Section -->
<div th:if="${finalAsset.assetJson != null}" class="mt-8 border-t border-white/10 pt-4">
<h4 class="text-sm font-bold text-success mb-2">Attached JSON Data</h4>
<pre class="bg-black/20 p-4 rounded text-xs text-success font-mono overflow-x-auto"
th:text="${finalAsset.assetJson}"></pre>
</div>
</div>
<!-- Footer -->
<div
style="padding: 16px 24px; background-color: #1a1b26; border-top: 1px solid rgba(255,255,255,0.1); display: flex; justify-content: flex-end; gap: 12px;">
<button onclick="saveFinalAsset()" class="btn btn-primary"
style="padding: 8px 16px; border-radius: 8px; font-weight: 500; display: flex; align-items: center; gap: 6px;">
<i data-lucide="save" style="width: 16px; height: 16px;"></i> Save Changes
</button>
<button onclick="copyFinalText()"
style="padding: 8px 16px; background-color: #374151; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; display: flex; align-items: center; gap: 6px;">
<i data-lucide="copy" style="width: 16px; height: 16px;"></i> Copy All
</button>
</div>
</div>
</div>
</div>
<!-- 4. Opening Generation Tab -->
<div id="tab-opening" class="tab-content hidden">
<div class="flex justify-between items-center mb-6 mobile-col mobile-items-start mobile-gap-4">
<h3 class="font-bold text-lg">Opening Generation</h3>
<div class="flex gap-2">
<button class="btn btn-primary" onclick="generateOpening()">
<i data-lucide="download-cloud" style="width: 18px; margin-right: 6px;"></i> Crawl Opening
</button>
<button class="btn btn-ghost border border-red-500/50 text-red-500 hover:bg-red-500/10"
onclick="resetOpening()">
<i data-lucide="trash-2" style="width: 18px; margin-right: 6px;"></i> Clear Content
</button>
</div>
</div>
<div class="card" style="background: #1e1e1e; min-height: 300px;">
<div style="display: flex; justify-content: flex-end; margin-bottom: 8px;">
<button onclick="copyToClipboard('opening-script-text')"
class="text-xs text-muted hover:text-white flex items-center gap-1">
<i data-lucide="copy" style="width: 12px;"></i> Copy
</button>
</div>
<pre id="opening-script-text" class="text-sm font-mono text-gray-300"
style="white-space: pre-wrap; word-break: break-all;"
th:text="${video.openingScript} ?: 'No opening script generated yet.'"></pre>
</div>
</div>
</div>
</div>
<!-- Page Specific Script -->
<th:block layout:fragment="script">
<script th:inline="javascript">
const videoId = [[${ video.metadata.videoId }]];
// Tab State Persistence
function switchTab(tabName) {
// Save state
localStorage.setItem('activeVideoTab_' + videoId, tabName);
// Hide all content
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
// Tab Button Styles
// Inactive: text-muted, transparent bg
// Active: bg-primary, text-white, shadow
const inactiveClasses = ["text-muted", "bg-transparent"];
const activeClasses = ["bg-primary", "text-white", "shadow-md"];
// Reset all items
document.querySelectorAll('.tab-item').forEach(btn => {
btn.classList.remove(...activeClasses);
btn.classList.add(...inactiveClasses);
});
// Activate target
const activeBtn = document.getElementById('btn-tab-' + tabName);
if (activeBtn) {
activeBtn.classList.remove(...inactiveClasses);
activeBtn.classList.add(...activeClasses);
}
// Show target content
document.getElementById('tab-' + tabName).classList.remove('hidden');
}
// Copy helper
async function copyToClipboard(elementId) {
const content = document.getElementById(elementId).innerText;
try {
await navigator.clipboard.writeText(content);
alert('Copied to clipboard!');
} catch (err) {
console.error('Failed to copy: ', err);
}
}
async function copyFinalText() {
// Use the text content div
const content = document.getElementById('final-asset-content').value;
try {
await navigator.clipboard.writeText(content);
alert('Final script copied!');
} catch (err) {
console.error('Failed to copy: ', err);
}
}
// Initialize on Load
document.addEventListener("DOMContentLoaded", () => {
// Restore tab
const savedTab = localStorage.getItem('activeVideoTab_' + videoId);
if (savedTab) {
switchTab(savedTab);
} else {
switchTab('script'); // Default
}
});
async function generateScript() {
if (!confirm('Generate Python Script? This might take a while.')) return;
const btn = document.getElementById('btn-gen-script');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="animate-spin" data-lucide="loader-2" style="width: 18px; margin-right: 6px;"></i> Generating...';
lucide.createIcons();
try {
const res = await fetch(`/api/videos/${videoId}/script`, { method: 'POST' });
if (res.ok) window.location.reload();
else {
alert('Failed');
btn.disabled = false;
btn.innerHTML = originalText;
}
} catch (e) {
alert('Error: ' + e);
btn.disabled = false;
btn.innerHTML = originalText;
}
}
async function generateDraft() {
const feedback = document.getElementById('draft-feedback').value;
// Get selected radio
const modeCpt = document.querySelector('input[name="draftMode"]:checked');
const mode = modeCpt ? modeCpt.value : 'TRUE_STORY'; // Default
if (!confirm('Generate Opal Draft?')) return;
const btn = event.currentTarget;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="animate-spin" data-lucide="loader-2" style="width: 18px; margin-right: 6px;"></i> Generating...';
lucide.createIcons();
try {
const res = await fetch(`/api/videos/${videoId}/drafts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ feedback: feedback, mode: mode })
});
if (res.ok) window.location.reload();
else {
alert('Failed');
btn.disabled = false;
btn.innerHTML = originalText;
}
} catch (e) {
alert('Error: ' + e);
btn.disabled = false;
btn.innerHTML = originalText;
}
}
async function acceptDraft(draftId) {
if (!confirm('Accept this draft?')) return;
const btn = event.currentTarget;
btn.disabled = true;
btn.innerText = 'Accepting...';
try {
const res = await fetch(`/api/videos/${videoId}/drafts/${draftId}/accept`, { method: 'POST' });
if (res.ok) window.location.reload();
else {
alert('Failed');
btn.disabled = false;
btn.innerText = 'Accept This';
}
} catch (e) { alert('Error: ' + e); btn.disabled = false; btn.innerText = 'Accept This'; }
}
async function generateFinalAsset() {
if (!confirm('Generate Final Asset?')) return;
const btn = event.currentTarget;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="animate-spin" data-lucide="loader-2" style="width: 18px; margin-right: 6px;"></i> Generating...';
lucide.createIcons();
try {
const res = await fetch(`/api/videos/${videoId}/final-asset`, { method: 'POST' });
if (res.ok) window.location.reload();
else {
alert('Failed');
btn.disabled = false;
btn.innerHTML = originalText;
}
} catch (e) {
alert('Error: ' + e);
btn.disabled = false;
btn.innerHTML = originalText;
}
}
async function saveFinalAsset() {
const content = document.getElementById('final-asset-content').value;
if (!confirm('Save changes to Final Asset?')) return;
const btn = event.currentTarget;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="animate-spin" data-lucide="loader-2" style="width: 16px; height: 16px; margin-right: 6px;"></i> Saving...';
lucide.createIcons();
try {
const res = await fetch(`/api/videos/${videoId}/final-asset`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ finalScriptText: content })
});
if (res.ok) {
alert('Saved successfully!');
window.location.reload();
} else {
alert('Failed to save.');
btn.disabled = false;
btn.innerHTML = originalText;
}
} catch (e) {
alert('Error: ' + e);
btn.disabled = false;
btn.innerHTML = originalText;
}
}
async function toggleComplete() {
const btn = document.getElementById('btn-toggle-complete');
btn.disabled = true;
try {
const res = await fetch(`/api/videos/${videoId}/complete`, { method: 'POST' });
if (res.ok) {
window.location.reload();
} else {
alert('Failed to toggle completion status.');
btn.disabled = false;
}
} catch (e) {
alert('Error: ' + e);
btn.disabled = false;
}
}
async function generateOpening() {
if (!confirm('Crawl Opening Script? This will verify and clear the external Google Doc.')) return;
const btn = event.currentTarget;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="animate-spin" data-lucide="loader-2" style="width: 18px; margin-right: 6px;"></i> Crawling...';
lucide.createIcons();
try {
const res = await fetch(`/api/videos/${videoId}/opening`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ docId: '1djTi1eo73zSyZIveChgS94S754hUS9JQX0NaaaSjHBo' })
});
if (res.ok) window.location.reload();
else {
alert('Failed');
btn.disabled = false;
btn.innerHTML = originalText;
}
} catch (e) {
alert('Error: ' + e);
btn.disabled = false;
btn.innerHTML = originalText;
}
}
async function resetOpening() {
if (!confirm('Clear Opening Content? This cannot be undone.')) return;
const btn = event.currentTarget;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerText = 'Clearing...';
try {
const res = await fetch(`/api/videos/${videoId}/opening`, { method: 'DELETE' });
if (res.ok) window.location.reload();
else {
alert('Failed');
btn.disabled = false;
btn.innerText = originalText;
}
} catch (e) {
alert('Error: ' + e);
btn.disabled = false;
btn.innerText = originalText;
}
}
</script>
<style>
.hidden {
display: none;
}
.border-glass {
border-color: var(--glass-border);
}
/* Custom Horizontal Scrollbar */
.custom-scrollbar::-webkit-scrollbar {
height: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
</style>
</th:block>
</body>
</html>