315 lines
12 KiB
Java
315 lines
12 KiB
Java
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_feed_topic", columnList = "feed_topic"), // 예능/정치 피드 분리
|
|
@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";
|
|
|
|
/** 피드 주제: ENTERTAINMENT | POLITICS. 기존 null 행은 예능으로 간주한다. */
|
|
@Column(name = "feed_topic", length = 20)
|
|
private String feedTopic = FeedTopic.ENTERTAINMENT.name();
|
|
|
|
/** 원본 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;
|
|
|
|
/**
|
|
* 다른 사이트에 임베드할 수 있는가(YouTube status.embeddable).
|
|
* 방송사·연예 채널은 막아둔 경우가 많아(실측 9건 중 4건) 미리 알아야 헛클릭을 막는다.
|
|
* null 이면 아직 모름 — 일단 재생을 시도한다.
|
|
*/
|
|
@Column(name = "embeddable")
|
|
private Boolean embeddable;
|
|
|
|
/**
|
|
* 인물 추적으로 걸린 영상이면 그 인물명. 인물 탭은 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, FeedTopic feedTopic) {
|
|
this.ytChannelId = ytChannelId;
|
|
this.channelTitle = channelTitle;
|
|
this.subscriberCount = subscriberCount;
|
|
this.viewsPerSubRatio = viewsPerSubRatio;
|
|
this.source = source;
|
|
this.feedTopic = feedTopic == null ? FeedTopic.ENTERTAINMENT.name() : feedTopic.name();
|
|
}
|
|
|
|
/** 설명에서 추출한 해시태그(쉼표 구분)를 채운다. */
|
|
public void applyHashtags(String hashtags) {
|
|
this.hashtags = hashtags;
|
|
}
|
|
|
|
/** 임베드 가능 여부를 기록한다. */
|
|
public void applyEmbeddable(Boolean embeddable) {
|
|
this.embeddable = embeddable;
|
|
}
|
|
|
|
/** 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;
|
|
if (this.feedTopic == null) this.feedTopic = FeedTopic.ENTERTAINMENT.name();
|
|
}
|
|
|
|
public FeedTopic feedTopicOrDefault() {
|
|
return FeedTopic.normalize(this.feedTopic);
|
|
}
|
|
|
|
/** 조회수 검색 결과로부터 수집 영상을 생성한다(채널 미연결). */
|
|
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;
|
|
}
|
|
}
|