쇼츠 클립 채널의 소재를 최신순으로 발굴하는 /feed 화면과 수집 파이프라인. - 소재 원본 탭: 웹예능 공식채널의 신규 롱폼(durationSec > 65)만 수집. 아직 아무도 안 자른 구간을 선점하는 용도라 공식계정 쇼츠는 제외한다. - 경쟁 쇼츠 탭: 예능짤 채널의 신규 쇼츠. 제목·썸네일 벤치마킹용이라 재가공 대신 원본 열기 액션을 준다. - Channel.role(MY/SOURCE/RIVAL) 컬럼 하나로 역할을 구분하고, 기존 uploads 플레이리스트 동기화를 그대로 재사용한다. 채널당 2 units라 search.list(100 units) 대비 쿼터가 거의 들지 않는다. - 3시간 주기 수집(FeedCollectionService). 소재 선점은 업로드 직후가 승부라 기존 일 1회 채널 수집으로는 늦다. - 수집함/발굴/떡상 후보 쿼리는 source 미지정 시 CHANNEL·SEARCH만 보도록 좁혀, 피드 영상이 기존 화면을 덮지 않게 격리했다. - 시드는 자동 후보 → 수동 승인. 내 채널 해시태그를 역분석해(HashtagExtractor) 공식채널 후보를 추천 목록에 쌓고, 승인 시 SOURCE/RIVAL로 등록한다. - 연속 3회 수집 실패한 시드는 자동 스킵해 쿼터 낭비를 막는다. - role이 null인 기존 채널은 부팅 시 MY로 1회 백필(ddl-auto:update 특성). UX: 기존 Editorial 디자인 시스템 유지. 골든타임(24h)·공식클립/풀에피· 떡상중·작업함 배지, 프로그램/길이/기간 필터, URL 상태 보존, 스켈레톤· 빈 상태·에러 복구 액션, 탭 키보드 이동, 이모지 대신 Lucide 아이콘. 테스트: HashtagExtractor·FeedBadges·ChannelRole 순수 로직 16건 추가(총 76건 통과). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
131 lines
3.9 KiB
Java
131 lines
3.9 KiB
Java
package com.hlab.yanalyst.domain.channel;
|
|
|
|
import jakarta.persistence.*;
|
|
import lombok.AccessLevel;
|
|
import lombok.Builder;
|
|
import lombok.Getter;
|
|
import lombok.NoArgsConstructor;
|
|
import org.springframework.data.annotation.CreatedDate;
|
|
import org.springframework.data.annotation.LastModifiedDate;
|
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
@Entity
|
|
@Table(name = "youtube_channels")
|
|
@Getter
|
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
|
@EntityListeners(AuditingEntityListener.class)
|
|
public class Channel {
|
|
|
|
@Id
|
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
private Long id;
|
|
|
|
@Column(nullable = false, unique = true)
|
|
private String channelId; // YouTube Channel ID
|
|
|
|
@Column(nullable = false)
|
|
private String title;
|
|
|
|
@Column(columnDefinition = "TEXT")
|
|
private String description;
|
|
|
|
@Column(length = 2083)
|
|
private String thumbnailUrl;
|
|
|
|
private Long subscriberCount;
|
|
|
|
private Long viewCount;
|
|
|
|
private Long videoCount;
|
|
|
|
@Column(name = "uploads_playlist_id")
|
|
private String uploadsPlaylistId;
|
|
|
|
@Column(length = 8)
|
|
private String country; // YouTube snippet.country (ISO-3166 alpha-2, 예: KR/JP/US). 없으면 null
|
|
|
|
/**
|
|
* 채널 역할: MY(내 채널) / SOURCE(소재 원본 웹예능) / RIVAL(경쟁 쇼츠).
|
|
* ddl-auto:update 특성상 기존 행은 null 이므로 조회 시 null 은 MY 로 간주한다({@link ChannelRole#normalize}).
|
|
*/
|
|
@Column(length = 10)
|
|
private String role = ChannelRole.MY;
|
|
|
|
/**
|
|
* 피드 수집 연속 실패 횟수. 3회 이상이면 자동 스킵해 쿼터 낭비를 막는다.
|
|
* 성공하면 0으로 리셋된다.
|
|
*/
|
|
@Column(name = "feed_fail_count")
|
|
private Integer feedFailCount = 0;
|
|
|
|
private LocalDateTime publishedAt;
|
|
|
|
@CreatedDate
|
|
@Column(updatable = false)
|
|
private LocalDateTime createdAt;
|
|
|
|
@LastModifiedDate
|
|
private LocalDateTime updatedAt;
|
|
|
|
@Builder
|
|
public Channel(String channelId, String title, String description, String thumbnailUrl, Long subscriberCount, Long viewCount, Long videoCount, LocalDateTime publishedAt, String uploadsPlaylistId) {
|
|
this.channelId = channelId;
|
|
this.title = title;
|
|
this.description = description;
|
|
this.thumbnailUrl = thumbnailUrl;
|
|
this.subscriberCount = subscriberCount;
|
|
this.viewCount = viewCount;
|
|
this.videoCount = videoCount;
|
|
this.publishedAt = publishedAt;
|
|
this.uploadsPlaylistId = uploadsPlaylistId;
|
|
}
|
|
|
|
public void update(String title, String description, String thumbnailUrl, Long subscriberCount, Long viewCount, Long videoCount) {
|
|
this.title = title;
|
|
this.description = description;
|
|
this.thumbnailUrl = thumbnailUrl;
|
|
this.subscriberCount = subscriberCount;
|
|
this.viewCount = viewCount;
|
|
this.videoCount = videoCount;
|
|
}
|
|
|
|
public void setUploadsPlaylistId(String uploadsPlaylistId) {
|
|
this.uploadsPlaylistId = uploadsPlaylistId;
|
|
}
|
|
|
|
public void setCountry(String country) {
|
|
this.country = country;
|
|
}
|
|
|
|
/** 역할 변경. null/미인식 값은 MY 로 보정된다. */
|
|
public void changeRole(String role) {
|
|
this.role = ChannelRole.normalize(role);
|
|
}
|
|
|
|
/** null 을 MY 로 보정한 실제 역할. */
|
|
public String roleOrDefault() {
|
|
return ChannelRole.normalize(this.role);
|
|
}
|
|
|
|
public int feedFailCountOrZero() {
|
|
return this.feedFailCount == null ? 0 : this.feedFailCount;
|
|
}
|
|
|
|
/** 피드 수집 실패 누적. */
|
|
public void recordFeedFailure() {
|
|
this.feedFailCount = feedFailCountOrZero() + 1;
|
|
}
|
|
|
|
/** 피드 수집 성공 — 실패 카운터 리셋. */
|
|
public void resetFeedFailure() {
|
|
this.feedFailCount = 0;
|
|
}
|
|
|
|
/** 연속 실패로 자동 비활성화된 상태인지. */
|
|
public boolean isFeedDisabled() {
|
|
return feedFailCountOrZero() >= 3;
|
|
}
|
|
}
|