package com.hlab.yanalyst.domain.channel; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import java.math.BigDecimal; import java.time.LocalDateTime; @Entity @Table(name = "channel_videos", indexes = { @Index(name = "idx_cv_video_id", columnList = "video_id"), // 조회/중복스킵 lookup @Index(name = "idx_cv_channel_id", columnList = "channel_id"), // 채널별 영상 @Index(name = "idx_cv_interest_status", columnList = "interest_status"), // 큐레이션/칸반 필터·집계 @Index(name = "idx_cv_source", columnList = "source"), // 출처 필터·집계 @Index(name = "idx_cv_category_id", columnList = "category_id"), // 카테고리 필터·집계 @Index(name = "idx_cv_published_at", columnList = "published_at"), // 게시일 정렬·발굴 필터 @Index(name = "idx_cv_views_per_sub_ratio", columnList = "views_per_sub_ratio") // 떡상 배율 정렬(발굴/후보) }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ChannelVideo { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String videoId; @Column(nullable = false) private String title; @Column(length = 2083) private String thumbnailUrl; private LocalDateTime publishedAt; private Long viewCount; private Long likeCount; private String duration; // ISO 8601 duration string // --- 파생 분석 지표 (수집 시 자동 계산) --- /** 영상 길이(초). duration(ISO8601)을 파싱해 저장. */ @Column(name = "duration_sec") private Integer durationSec; /** Shorts 여부 (65초 이하). */ @Column(name = "is_shorts") private Boolean isShorts = false; /** 시간당 조회수 = 조회수 / 업로드 후 경과 시간. "떡상 속도" 지표. */ @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; // --- 출처/원본 채널 정보 (검색 수집 시 Channel 엔티티가 없을 수 있음) --- /** 수집 경로: CHANNEL(등록 채널 동기화) / SEARCH(조회수 검색 수집). */ @Column(name = "source", length = 20) private String source = "CHANNEL"; /** 원본 YouTube 채널 ID(문자열). FK Channel 과 별개로 항상 보관. */ @Column(name = "yt_channel_id") private String ytChannelId; /** 원본 채널명. */ @Column(name = "channel_title") private String channelTitle; /** 수집 시점 채널 구독자 수. */ @Column(name = "subscriber_count") private Long subscriberCount; /** 해시태그(쉼표 구분). */ @Column(name = "hashtags", columnDefinition = "TEXT") private String hashtags; /** * 인물 추적으로 걸린 영상이면 그 인물명. 인물 탭은 source 가 아니라 이 값으로 조회하므로, * 소스 채널에서 이미 수집한 영상이 인물 검색에도 걸리면 두 탭 모두에 나타난다. */ @Column(name = "matched_person", length = 100) private String matchedPerson; // --- 큐레이션(분류/관리) 필드 --- /** 분류 카테고리 ID (categories.id 참조, 느슨한 연결). */ @Column(name = "category_id") private Long categoryId; /** 관심 영상 북마크. */ @Column(name = "bookmarked") private Boolean bookmarked = false; /** 큐레이션 상태: NEW(수집됨) / REVIEWING(검토중) / TARGET(작업대상) / EXCLUDED(제외). */ @Column(name = "interest_status", length = 20) private String interestStatus = "NEW"; /** 사용자 메모. */ @Column(name = "memo", columnDefinition = "TEXT") private String memo; /** 재가공(재작성) 초안 — 원본 스크립트를 바탕으로 수정한 내 버전. */ @Column(name = "rework_text", columnDefinition = "TEXT") private String reworkText; @Column(name = "has_script") private Boolean hasScript = false; /** * 영상 내용 요약(Gemini가 영상을 직접 보고 만든 것). 댓글 답글 초안의 근거로 쓴다. * 영상당 1회만 만들고 재사용한다 — 같은 영상 댓글마다 다시 분석하면 낭비다. */ @Column(name = "context_summary", columnDefinition = "TEXT") private String contextSummary; @com.fasterxml.jackson.annotation.JsonIgnore @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "channel_id") private Channel channel; @Builder public ChannelVideo(String videoId, String title, String thumbnailUrl, LocalDateTime publishedAt, Long viewCount, Long likeCount, String duration, Channel channel) { this.videoId = videoId; this.title = title; this.thumbnailUrl = thumbnailUrl; this.publishedAt = publishedAt; this.viewCount = viewCount; this.likeCount = likeCount; this.duration = duration; this.channel = channel; } public void update(String title, String thumbnailUrl, Long viewCount, Long likeCount) { this.title = title; this.thumbnailUrl = thumbnailUrl; this.viewCount = viewCount; this.likeCount = likeCount; } /** 수집/갱신 시 파생 분석 지표를 일괄 적용한다. */ public void applyMetrics(Integer durationSec, Boolean isShorts, BigDecimal viewsPerHour) { this.durationSec = durationSec; this.isShorts = isShorts; this.viewsPerHour = viewsPerHour; } /** 채널 수집 시 원본 채널 정보 + 구독자 대비 비율 적용. */ public void applyChannelInfo(String ytChannelId, String channelTitle, Long subscriberCount, BigDecimal viewsPerSubRatio) { this.ytChannelId = ytChannelId; this.channelTitle = channelTitle; this.subscriberCount = subscriberCount; this.viewsPerSubRatio = viewsPerSubRatio; this.source = "CHANNEL"; } /** * 피드(소재 원본/경쟁) 수집 시 원본 채널 정보와 출처를 함께 적용한다. * @param source SOURCE(소재 원본 롱폼) | RIVAL(경쟁 쇼츠) */ public void applyFeedInfo(String ytChannelId, String channelTitle, Long subscriberCount, BigDecimal viewsPerSubRatio, String source) { this.ytChannelId = ytChannelId; this.channelTitle = channelTitle; this.subscriberCount = subscriberCount; this.viewsPerSubRatio = viewsPerSubRatio; this.source = source; } /** 설명에서 추출한 해시태그(쉼표 구분)를 채운다. */ public void applyHashtags(String hashtags) { this.hashtags = hashtags; } /** Gemini 가 영상을 보고 만든 내용 요약을 저장한다(영상당 1회). */ public void applyContextSummary(String contextSummary) { this.contextSummary = contextSummary; } /** 인물 추적으로 걸린 인물명을 기록한다. */ public void applyMatchedPerson(String matchedPerson) { this.matchedPerson = matchedPerson; } /** * 인물 검색으로 새로 발견한 영상을 만든다. 소스 채널 시드에 없는 채널까지 잡히므로 * 출처를 PERSON 으로 둬 수집함/발굴에서는 격리한다. */ public static ChannelVideo fromPersonSearch(String videoId, String title, String thumbnailUrl, LocalDateTime publishedAt, Long viewCount, String ytChannelId, String channelTitle, Integer durationSec, BigDecimal viewsPerHour, String hashtags, String matchedPerson) { ChannelVideo v = new ChannelVideo(); v.videoId = videoId; v.title = title; v.thumbnailUrl = thumbnailUrl; v.publishedAt = publishedAt; v.viewCount = viewCount; v.likeCount = 0L; v.ytChannelId = ytChannelId; v.channelTitle = channelTitle; v.durationSec = durationSec; v.isShorts = VideoMetrics.isShorts(durationSec); v.viewsPerHour = viewsPerHour; v.hashtags = hashtags; v.matchedPerson = matchedPerson; v.source = "PERSON"; return v; } /** 출처(source)를 바꾸지 않고 채널 정보/비율만 채운다. 백필 시 SEARCH 수집물용. */ public void applyChannelInfoKeepSource(String ytChannelId, String channelTitle, Long subscriberCount, BigDecimal viewsPerSubRatio) { this.ytChannelId = ytChannelId; this.channelTitle = channelTitle; this.subscriberCount = subscriberCount; this.viewsPerSubRatio = viewsPerSubRatio; } /** null 인 큐레이션 필드에 기본값을 채운다(백필용). */ public void applyCurationDefaults() { if (this.source == null) this.source = "CHANNEL"; if (this.interestStatus == null) this.interestStatus = "NEW"; if (this.bookmarked == null) this.bookmarked = false; } /** 조회수 검색 결과로부터 수집 영상을 생성한다(채널 미연결). */ public static ChannelVideo fromSearch(String videoId, String title, String thumbnailUrl, LocalDateTime publishedAt, Long viewCount, String ytChannelId, String channelTitle, Long subscriberCount, Integer durationSec, BigDecimal viewsPerHour, BigDecimal viewsPerSubRatio, String hashtags) { ChannelVideo v = new ChannelVideo(); v.videoId = videoId; v.title = title; v.thumbnailUrl = thumbnailUrl; v.publishedAt = publishedAt; v.viewCount = viewCount; v.likeCount = 0L; v.ytChannelId = ytChannelId; v.channelTitle = channelTitle; v.subscriberCount = subscriberCount; v.durationSec = durationSec; v.isShorts = VideoMetrics.isShorts(durationSec); v.viewsPerHour = viewsPerHour; v.viewsPerSubRatio = viewsPerSubRatio; v.hashtags = hashtags; v.source = "SEARCH"; return v; } public void assignCategory(Long categoryId) { this.categoryId = categoryId; } public void setBookmarked(Boolean bookmarked) { this.bookmarked = bookmarked; } public void changeInterestStatus(String interestStatus) { this.interestStatus = interestStatus; } public void setMemo(String memo) { this.memo = memo; } public void setReworkText(String reworkText) { this.reworkText = reworkText; } public void setHasScript(Boolean hasScript) { this.hasScript = hasScript; } public boolean isHasScript() { return this.hasScript != null && this.hasScript; } public boolean isBookmarked() { return this.bookmarked != null && this.bookmarked; } }