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:
parent
e862498f96
commit
9bd7e80542
@ -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 *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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";
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
package com.hlab.yanalyst.domain.script;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
public interface ScriptGenRepository extends JpaRepository<ScriptGen, Long> {
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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");
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
package com.hlab.yanalyst.web.dto;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
public class VideoAddRequest {
|
|
||||||
private String url;
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
Loading…
Reference in New Issue
Block a user