feat: 정치 피드와 숏폼 큐 분류 추가

This commit is contained in:
hehihoho3@gmail.com 2026-08-12 11:09:03 +09:00
parent b8b119a94a
commit d75885a42b
23 changed files with 373 additions and 72 deletions

33
AGENTS.md Normal file
View File

@ -0,0 +1,33 @@
# Repository Guidelines
## Project Structure & Module Organization
This is a Java 21, Spring Boot 3.4 Gradle application. Production code lives under `src/main/java/com/hlab/yanalyst`. Keep feature entities, repositories, services, controllers, and DTOs in `domain/<feature>/`; shared configuration, scheduling, errors, and response types belong in `global/`. Cross-feature integrations are in `service/`, while page controllers and general APIs are in `web/`.
Templates are in `src/main/resources/templates`, browser assets in `src/main/resources/static`, and application configuration in `src/main/resources/application*.yml`. Tests mirror production packages under `src/test/java`; fixtures belong in `src/test/resources`. Design notes and implementation plans live in `docs/superpowers/`.
## Build, Test, and Development Commands
Use the checked-in Gradle wrapper; on Windows run:
- `.\gradlew.bat bootRun`: start the UI and API at `http://localhost:8088`.
- `.\gradlew.bat test`: run the JUnit 5 test suite.
- `.\gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.SrtFormatterTest"`: run one test class.
- `.\gradlew.bat build`: compile, test, and create the application artifact.
- `.\gradlew.bat clean build`: rebuild from clean output.
## Coding Style & Naming Conventions
Use four-space indentation and standard Java conventions: `PascalCase` types, `camelCase` members, and lowercase packages. Name Spring components by responsibility (`ChannelService`, `FeedController`, `ChannelRepository`) and DTOs with a `Dto` suffix. Use existing `ApiResponse<T>` wrappers for JSON endpoints. Keep Thymeleaf pages consistent with `layout/base.html` and reuse `static/css/variables.css`. No formatter is enforced, so match nearby code.
## Testing Guidelines
Tests use JUnit 5, Spring Boot Test, and Mockito. Name test classes `<Subject>Test` and test methods for observable behavior, such as `parsesShortLink`. Add unit tests for parsing and domain logic; use Spring context tests only when integration behavior requires them. There is no explicit coverage threshold, but changed logic should include regression coverage.
## Commit & Pull Request Guidelines
Recent commits use short prefixes such as `feat:` and `docs:`, followed by a concise Korean description. Keep each commit scoped to one change. Pull requests should explain the purpose, summarize implementation and verification, link relevant issues or design documents, and include screenshots for Thymeleaf/CSS changes. Call out configuration or database-schema effects explicitly.
## Security & Configuration
Copy `application-local.yml.example` to the ignored `application-local.yml` for local settings. Never commit database credentials, API keys, Google OAuth files, `tokens/`, logs, downloads, or generated media. Use environment variables such as `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`, and `YOUTUBE_API_KEY`.

View File

@ -53,6 +53,14 @@ public class Channel {
@Column(length = 10)
private String role = ChannelRole.MY;
/** 피드 분류. 기존 null 행은 예능으로 간주한다. */
@Column(name = "feed_topic", length = 20)
private String feedTopic = FeedTopic.ENTERTAINMENT.name();
/** 시드별 수집 길이 규칙. 기존 null 행은 10분 이상 롱폼으로 간주한다. */
@Column(name = "feed_format", length = 20)
private String feedFormat = FeedFormat.LONG_FORM.name();
/**
* 피드 수집 연속 실패 횟수. 3회 이상이면 자동 스킵해 쿼터 낭비를 막는다.
* 성공하면 0으로 리셋된다.
@ -109,6 +117,19 @@ public class Channel {
return ChannelRole.normalize(this.role);
}
public void configureFeed(String topic, String format) {
this.feedTopic = FeedTopic.normalize(topic).name();
this.feedFormat = FeedFormat.normalize(format).name();
}
public FeedTopic feedTopicOrDefault() {
return FeedTopic.normalize(this.feedTopic);
}
public FeedFormat feedFormatOrDefault() {
return FeedFormat.normalize(this.feedFormat);
}
public int feedFailCountOrZero() {
return this.feedFailCount == null ? 0 : this.feedFailCount;
}

View File

@ -293,7 +293,7 @@ public class ChannelService {
private void processVideos(Channel channel, List<String> videoIds) {
// 채널(OWN) 영상은 소재가 아니라 성과라 출처를 달리해 수집함/발굴에서 격리한다.
upsertVideos(channel, videoIds, ChannelRole.videoSource(channel.roleOrDefault()), null, null, null);
upsertVideos(channel, videoIds, ChannelRole.videoSource(channel.roleOrDefault()), null, null, null, null);
}
/**
@ -306,7 +306,7 @@ public class ChannelService {
* @return 저장·갱신한 영상
*/
private int upsertVideos(Channel channel, List<String> videoIds, String source,
Integer minDurationSec, LocalDateTime publishedAfter,
Integer minDurationSec, Integer maxDurationSec, LocalDateTime publishedAfter,
List<ChannelVideo> newInsertsOut) {
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
// status 임베드 가능 여부(embeddable) 때문에 필요하다. part 늘려도 쿼터는 그대로다.
@ -343,6 +343,7 @@ public class ChannelService {
// 길이/기간 필터 피드 수집에서만 사용(일반 채널 수집은 null)
if (minDurationSec != null && (durationSec == null || durationSec < minDurationSec)) continue;
if (maxDurationSec != null && (durationSec == null || durationSec > maxDurationSec)) continue;
if (publishedAfter != null && publishedAt.isBefore(publishedAfter)) continue;
java.math.BigDecimal viewsPerHour = VideoMetrics.viewsPerHour(viewCount, publishedAt);
@ -361,7 +362,8 @@ public class ChannelService {
.ifPresentOrElse(v -> {
v.update(title, thumbnailUrl, viewCount, likeCount);
v.applyMetrics(durationSec, isShorts, viewsPerHour);
v.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source);
v.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio,
source, channel.feedTopicOrDefault());
v.applyHashtags(hashtags);
v.applyEmbeddable(embeddable);
channelVideoRepository.save(v);
@ -377,7 +379,8 @@ public class ChannelService {
.duration(duration)
.build();
newVideo.applyMetrics(durationSec, isShorts, viewsPerHour);
newVideo.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source);
newVideo.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio,
source, channel.feedTopicOrDefault());
newVideo.applyHashtags(hashtags);
newVideo.applyEmbeddable(embeddable);
channelVideoRepository.save(newVideo);
@ -403,7 +406,7 @@ public class ChannelService {
* @throws IllegalStateException uploads 플레이리스트를 찾을 없을
*/
@Transactional
public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter, int minDurationSec,
public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter,
List<ChannelVideo> newInsertsOut) {
String role = channel.roleOrDefault();
String uploadsPlaylistId = channel.getUploadsPlaylistId();
@ -429,7 +432,9 @@ public class ChannelService {
if (videoIds.isEmpty()) return 0;
String source = ChannelRole.RIVAL.equals(role) ? ChannelRole.RIVAL : ChannelRole.SOURCE;
return upsertVideos(channel, videoIds, source, minDurationSec, publishedAfter, newInsertsOut);
FeedFormat format = channel.feedFormatOrDefault();
return upsertVideos(channel, videoIds, source, format.minDurationSec(), format.maxDurationSec(),
publishedAfter, newInsertsOut);
}

View File

@ -15,6 +15,7 @@ import java.time.LocalDateTime;
@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") // 떡상 배율 정렬(발굴/후보)
@ -66,6 +67,10 @@ public class ChannelVideo {
@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;
@ -174,12 +179,13 @@ public class ChannelVideo {
* @param source SOURCE(소재 원본 롱폼) | RIVAL(경쟁 쇼츠)
*/
public void applyFeedInfo(String ytChannelId, String channelTitle, Long subscriberCount,
BigDecimal viewsPerSubRatio, String source) {
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();
}
/** 설명에서 추출한 해시태그(쉼표 구분)를 채운다. */
@ -242,6 +248,11 @@ public class ChannelVideo {
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);
}
/** 조회수 검색 결과로부터 수집 영상을 생성한다(채널 미연결). */

View File

@ -33,6 +33,7 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
* @param hideWorked true 아직 손대지 않은(NEW) 것만
*/
@Query("select v from ChannelVideo v where v.source = :source "
+ "and coalesce(v.feedTopic, 'ENTERTAINMENT') = :topic "
+ "and v.interestStatus <> 'EXCLUDED' "
+ "and (cast(:publishedAfter as timestamp) is null or v.publishedAt >= :publishedAfter) "
+ "and (:ytChannelId is null or v.ytChannelId = :ytChannelId) "
@ -41,6 +42,7 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
+ "and (:hideWorked = false or v.interestStatus = 'NEW') "
+ "order by v.publishedAt desc")
java.util.List<ChannelVideo> feed(@Param("source") String source,
@Param("topic") String topic,
@Param("publishedAfter") java.time.LocalDateTime publishedAfter,
@Param("ytChannelId") String ytChannelId,
@Param("minDurationSec") Integer minDurationSec,
@ -74,9 +76,10 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
/** 피드 필터용 프로그램(채널) 목록: [ytChannelId, channelTitle, 영상수]. 영상 많은 순. */
@Query("select v.ytChannelId, min(v.channelTitle), count(v) from ChannelVideo v "
+ "where v.source = :source and v.ytChannelId is not null "
+ "where v.source = :source and coalesce(v.feedTopic, 'ENTERTAINMENT') = :topic "
+ "and v.ytChannelId is not null "
+ "group by v.ytChannelId order by count(v) desc")
java.util.List<Object[]> feedPrograms(@Param("source") String source);
java.util.List<Object[]> feedPrograms(@Param("source") String source, @Param("topic") String topic);
/** 떡상 후보: 구독자 대비 조회수 비율이 높은 Shorts (제외 처리된 것은 빼고). 피드 수집물은 제외. */
@Query("select v from ChannelVideo v where v.isShorts = true "

View File

@ -68,7 +68,14 @@ public class FeedCollectionService {
/** 수동/스케줄 공용. 모든 피드 시드를 쿼터 한도 안에서 수집하고 요약을 반환한다. */
public Map<String, Object> collectAll() {
List<Channel> seeds = channelRepository.findFeedSeeds();
return collect(null);
}
/** 특정 주제만 즉시 수집한다. null은 스케줄러용 전체 수집이다. */
public Map<String, Object> collect(FeedTopic topic) {
List<Channel> seeds = channelRepository.findFeedSeeds().stream()
.filter(c -> topic == null || c.feedTopicOrDefault() == topic)
.toList();
LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays);
int ok = 0, failed = 0, skippedByQuota = 0, disabled = 0, saved = 0;
@ -86,7 +93,7 @@ public class FeedCollectionService {
}
try {
List<ChannelVideo> newOnes = new java.util.ArrayList<>();
saved += channelService.collectFeedVideos(c, publishedAfter, minDurationSec, newOnes);
saved += channelService.collectFeedVideos(c, publishedAfter, newOnes);
// 경쟁(RIVAL) 채널의 신규 영상은 경쟁자의 결과물이라 골든타임 알림 대상이 아니다
if (ChannelRole.SOURCE.equals(c.roleOrDefault())) goldenCandidates.addAll(newOnes);
markSuccess(c.getId());

View File

@ -29,12 +29,13 @@ public class FeedController {
@Operation(summary = "피드 조회",
description = "tab=SOURCE(롱폼 소재)|RIVAL(경쟁 쇼츠)|PERSON(인물 추적 롱폼). 항상 최신순.")
public ApiResponse<List<FeedItemDto>> feed(@RequestParam(defaultValue = "SOURCE") String tab,
@RequestParam(defaultValue = "ENTERTAINMENT") String topic,
@RequestParam(required = false) Integer days,
@RequestParam(required = false) String channelId,
@RequestParam(required = false) String lengthBucket,
@RequestParam(defaultValue = "false") boolean hideWorked,
@RequestParam(required = false) String person) {
return ApiResponse.ok(feedService.feed(tab, days, channelId, lengthBucket, hideWorked, person));
return ApiResponse.ok(feedService.feed(tab, topic, days, channelId, lengthBucket, hideWorked, person));
}
// --- 인물 추적 ---
@ -73,22 +74,25 @@ public class FeedController {
@GetMapping("/programs")
@Operation(summary = "프로그램 목록", description = "피드 필터용 원본 채널 목록(영상 많은 순)")
public ApiResponse<List<FeedProgramDto>> programs(@RequestParam(defaultValue = "SOURCE") String tab) {
return ApiResponse.ok(feedService.programs(tab));
public ApiResponse<List<FeedProgramDto>> programs(@RequestParam(defaultValue = "SOURCE") String tab,
@RequestParam(defaultValue = "ENTERTAINMENT") String topic) {
return ApiResponse.ok(feedService.programs(tab, topic));
}
@PostMapping("/collect")
@Operation(summary = "수동 수집", description = "스케줄과 동일한 피드 수집을 즉시 1회 실행")
public ApiResponse<Map<String, Object>> collect() {
return ApiResponse.ok(feedCollectionService.collectAll());
public ApiResponse<Map<String, Object>> collect(
@RequestParam(defaultValue = "ENTERTAINMENT") String topic) {
return ApiResponse.ok(feedCollectionService.collect(FeedTopic.normalize(topic)));
}
// --- 시드(소스/경쟁 채널) 관리 ---
@GetMapping("/seeds")
@Operation(summary = "시드 목록", description = "등록된 소재 원본·경쟁 채널")
public ApiResponse<List<FeedSeedDto>> seeds() {
return ApiResponse.ok(feedService.seeds());
public ApiResponse<List<FeedSeedDto>> seeds(
@RequestParam(defaultValue = "ENTERTAINMENT") String topic) {
return ApiResponse.ok(feedService.seeds(topic));
}
@PostMapping("/seeds")
@ -96,7 +100,9 @@ public class FeedController {
public ApiResponse<FeedSeedDto> addSeed(@RequestBody Map<String, String> body) {
String url = body.get("url");
if (url == null || url.isBlank()) throw new IllegalArgumentException("채널 URL 이 필요합니다.");
return ApiResponse.created(feedService.addSeed(url, body.getOrDefault("role", ChannelRole.SOURCE)));
return ApiResponse.created(feedService.addSeed(url, body.getOrDefault("role", ChannelRole.SOURCE),
body.getOrDefault("topic", FeedTopic.ENTERTAINMENT.name()),
body.getOrDefault("format", FeedFormat.LONG_FORM.name())));
}
@DeleteMapping("/seeds/{id}")

View File

@ -0,0 +1,28 @@
package com.hlab.yanalyst.domain.channel;
public enum FeedFormat {
LONG_FORM(600, null),
VIDEO(66, null),
SHORTS(null, 65),
ALL(null, null);
private final Integer minDurationSec;
private final Integer maxDurationSec;
FeedFormat(Integer minDurationSec, Integer maxDurationSec) {
this.minDurationSec = minDurationSec;
this.maxDurationSec = maxDurationSec;
}
public Integer minDurationSec() { return minDurationSec; }
public Integer maxDurationSec() { return maxDurationSec; }
public static FeedFormat normalize(String value) {
if (value == null || value.isBlank()) return LONG_FORM;
try {
return valueOf(value.trim().toUpperCase());
} catch (IllegalArgumentException ignored) {
return LONG_FORM;
}
}
}

View File

@ -52,15 +52,16 @@ public class FeedService {
*/
public List<FeedItemDto> feed(String tab, Integer days, String ytChannelId,
String lengthBucket, boolean hideWorked) {
return feed(tab, days, ytChannelId, lengthBucket, hideWorked, null);
return feed(tab, FeedTopic.ENTERTAINMENT.name(), days, ytChannelId, lengthBucket, hideWorked, null);
}
/**
* @param person 인물 탭에서 특정 인물만 (null/빈값이면 전체)
*/
public List<FeedItemDto> feed(String tab, Integer days, String ytChannelId,
public List<FeedItemDto> feed(String tab, String topic, Integer days, String ytChannelId,
String lengthBucket, boolean hideWorked, String person) {
String source = normalizeTab(tab);
String normalizedTopic = FeedTopic.normalize(topic).name();
LocalDateTime now = LocalDateTime.now();
LocalDateTime publishedAfter = (days == null || days <= 0) ? null : now.minusDays(days);
String channelFilter = (ytChannelId == null || ytChannelId.isBlank()) ? null : ytChannelId;
@ -84,10 +85,11 @@ public class FeedService {
}
}
// 수집 하한을 화면에도 적용 하한을 올렸을 과거에 담긴 짧은 영상이 남아 보이지 않게
minSec = (minSec == null) ? Integer.valueOf(minDurationSec) : Integer.valueOf(Math.max(minSec, minDurationSec));
int topicFloor = FeedTopic.POLITICS.name().equals(normalizedTopic) ? 66 : minDurationSec;
minSec = (minSec == null) ? Integer.valueOf(topicFloor) : Integer.valueOf(Math.max(minSec, topicFloor));
List<ChannelVideo> rows = channelVideoRepository.feed(
source, publishedAfter, channelFilter, minSec, maxSec, hideWorked,
source, normalizedTopic, publishedAfter, channelFilter, minSec, maxSec, hideWorked,
PageRequest.of(0, MAX_ITEMS));
List<FeedItemDto> out = new ArrayList<>(rows.size());
@ -96,21 +98,25 @@ public class FeedService {
}
/** 필터 드롭다운용 프로그램(채널) 목록. */
public List<FeedProgramDto> programs(String tab) {
public List<FeedProgramDto> programs(String tab, String topic) {
String source = normalizeTab(tab);
String normalizedTopic = FeedTopic.normalize(topic).name();
List<FeedProgramDto> out = new ArrayList<>();
for (Object[] row : channelVideoRepository.feedPrograms(source)) {
for (Object[] row : channelVideoRepository.feedPrograms(source, normalizedTopic)) {
out.add(new FeedProgramDto((String) row[0], (String) row[1], (Long) row[2]));
}
return out;
}
/** 등록된 피드 시드 목록(소스 + 경쟁). */
public List<FeedSeedDto> seeds() {
public List<FeedSeedDto> seeds(String topic) {
FeedTopic normalizedTopic = FeedTopic.normalize(topic);
List<FeedSeedDto> out = new ArrayList<>();
for (Channel c : channelRepository.findFeedSeeds()) {
if (c.feedTopicOrDefault() != normalizedTopic) continue;
out.add(new FeedSeedDto(c.getId(), c.getChannelId(), c.getTitle(), c.getThumbnailUrl(),
c.getSubscriberCount(), c.roleOrDefault(), c.feedFailCountOrZero(), c.isFeedDisabled()));
c.getSubscriberCount(), c.roleOrDefault(), c.feedTopicOrDefault().name(),
c.feedFormatOrDefault().name(), c.feedFailCountOrZero(), c.isFeedDisabled()));
}
return out;
}
@ -121,17 +127,19 @@ public class FeedService {
* @param role SOURCE | RIVAL
*/
@Transactional
public FeedSeedDto addSeed(String url, String role) {
public FeedSeedDto addSeed(String url, String role, String topic, String format) {
String normalized = ChannelRole.normalize(role);
if (!ChannelRole.isFeed(normalized)) {
throw new IllegalArgumentException("시드 역할은 SOURCE 또는 RIVAL 이어야 합니다: " + role);
}
Channel channel = channelService.saveChannelFromUrl(url);
channel.changeRole(normalized);
channel.configureFeed(topic, format);
channel.resetFeedFailure();
channelRepository.save(channel);
return new FeedSeedDto(channel.getId(), channel.getChannelId(), channel.getTitle(),
channel.getThumbnailUrl(), channel.getSubscriberCount(), normalized, 0, false);
channel.getThumbnailUrl(), channel.getSubscriberCount(), normalized,
channel.feedTopicOrDefault().name(), channel.feedFormatOrDefault().name(), 0, false);
}
/** 시드 해제 — 채널과 그 채널에서 수집한 피드 영상을 함께 제거한다. */

View File

@ -0,0 +1,15 @@
package com.hlab.yanalyst.domain.channel;
public enum FeedTopic {
ENTERTAINMENT,
POLITICS;
public static FeedTopic normalize(String value) {
if (value == null || value.isBlank()) return ENTERTAINMENT;
try {
return valueOf(value.trim().toUpperCase());
} catch (IllegalArgumentException ignored) {
return ENTERTAINMENT;
}
}
}

View File

@ -8,4 +8,5 @@ package com.hlab.yanalyst.domain.channel.dto;
* @param disabled 연속 실패 3회로 자동 스킵 중인지
*/
public record FeedSeedDto(Long id, String channelId, String title, String thumbnailUrl,
Long subscriberCount, String role, int failCount, boolean disabled) {}
Long subscriberCount, String role, String topic, String format,
int failCount, boolean disabled) {}

View File

@ -0,0 +1,10 @@
package com.hlab.yanalyst.domain.shortform;
public enum ShortformCategory {
ENTERTAINMENT,
POLITICS;
public static ShortformCategory normalize(ShortformCategory value) {
return value == null ? ENTERTAINMENT : value;
}
}

View File

@ -20,12 +20,13 @@ public class ShortformController {
@PostMapping("/jobs")
public ApiResponse<JobDetail> register(@RequestBody RegisterRequest request) {
return ApiResponse.created(shortformService.register(request.youtubeUrl()));
return ApiResponse.created(shortformService.register(request.youtubeUrl(), request.category()));
}
@GetMapping("/jobs")
public ApiResponse<List<JobSummary>> list(@RequestParam(required = false) ShortformJobStatus status) {
return ApiResponse.ok(shortformService.list(status));
public ApiResponse<List<JobSummary>> list(@RequestParam(required = false) ShortformJobStatus status,
@RequestParam(required = false) ShortformCategory category) {
return ApiResponse.ok(shortformService.list(status, category));
}
@GetMapping("/jobs/{id}")
@ -40,7 +41,8 @@ public class ShortformController {
@PostMapping("/import")
public ApiResponse<JobDetail> importResult(@RequestBody ImportRequest request) {
return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText(), request.step1Text()));
return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText(),
request.step1Text(), request.category()));
}
@PostMapping("/jobs/{id}/reset")

View File

@ -32,6 +32,10 @@ public class ShortformJob {
@Column(nullable = false, length = 20)
private String videoId;
@Enumerated(EnumType.STRING)
@Column(name = "content_category", length = 20)
private ShortformCategory category = ShortformCategory.ENTERTAINMENT;
/** 결과의 첫 클립 title_main. 목록 표시에 쓴다. */
@Column(length = 300)
private String title;
@ -58,12 +62,25 @@ public class ShortformJob {
private List<ShortformClip> clips = new ArrayList<>();
public static ShortformJob create(String youtubeUrl, String videoId) {
return create(youtubeUrl, videoId, ShortformCategory.ENTERTAINMENT);
}
public static ShortformJob create(String youtubeUrl, String videoId, ShortformCategory category) {
ShortformJob job = new ShortformJob();
job.youtubeUrl = youtubeUrl;
job.videoId = videoId;
job.category = ShortformCategory.normalize(category);
return job;
}
public void changeCategory(ShortformCategory category) {
this.category = ShortformCategory.normalize(category);
}
public ShortformCategory categoryOrDefault() {
return ShortformCategory.normalize(this.category);
}
public void applyResult(String raw, List<ParsedClip> parsed) {
this.rawOutput = raw;
this.clips.clear();

View File

@ -1,6 +1,8 @@
package com.hlab.yanalyst.domain.shortform;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
@ -9,4 +11,14 @@ public interface ShortformJobRepository extends JpaRepository<ShortformJob, Long
Optional<ShortformJob> findByVideoId(String videoId);
List<ShortformJob> findAllByOrderByCreatedAtDesc();
List<ShortformJob> findByStatusOrderByCreatedAtAsc(ShortformJobStatus status);
@Query("select j from ShortformJob j where coalesce(j.category, "
+ "com.hlab.yanalyst.domain.shortform.ShortformCategory.ENTERTAINMENT) = :category "
+ "order by j.createdAt desc")
List<ShortformJob> findByCategoryOrderByCreatedAtDesc(@Param("category") ShortformCategory category);
@Query("select j from ShortformJob j where coalesce(j.category, "
+ "com.hlab.yanalyst.domain.shortform.ShortformCategory.ENTERTAINMENT) = :category "
+ "and j.status = :status order by j.createdAt asc")
List<ShortformJob> findByCategoryAndStatusOrderByCreatedAtAsc(
@Param("category") ShortformCategory category, @Param("status") ShortformJobStatus status);
}

View File

@ -18,10 +18,16 @@ public class ShortformService {
/** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */
@Transactional
public JobDetail register(String youtubeUrl) {
return register(youtubeUrl, ShortformCategory.ENTERTAINMENT);
}
@Transactional
public JobDetail register(String youtubeUrl, ShortformCategory category) {
String videoId = YoutubeVideoId.from(youtubeUrl)
.orElseThrow(() -> new IllegalArgumentException("유튜브 URL이 아닙니다: " + youtubeUrl));
ShortformJob job = jobRepository.findByVideoId(videoId)
.orElseGet(() -> jobRepository.save(ShortformJob.create(youtubeUrl.trim(), videoId)));
.orElseGet(() -> jobRepository.save(ShortformJob.create(youtubeUrl.trim(), videoId, category)));
job.changeCategory(category);
return JobDetail.from(job);
}
@ -42,16 +48,23 @@ public class ShortformService {
/** 등록 + 결과 저장 한 번에 (Opal 수동 실행 후 붙여넣기 경로). */
@Transactional
public JobDetail importResult(String youtubeUrl, String rawText, String step1Text) {
JobDetail registered = register(youtubeUrl);
public JobDetail importResult(String youtubeUrl, String rawText, String step1Text, ShortformCategory category) {
JobDetail registered = register(youtubeUrl, category);
return saveResult(registered.id(), rawText, step1Text);
}
@Transactional(readOnly = true)
public List<JobSummary> list(ShortformJobStatus status) {
List<ShortformJob> jobs = (status == null)
public List<JobSummary> list(ShortformJobStatus status, ShortformCategory category) {
List<ShortformJob> jobs;
if (category == null) {
jobs = (status == null)
? jobRepository.findAllByOrderByCreatedAtDesc()
: jobRepository.findByStatusOrderByCreatedAtAsc(status);
} else {
jobs = (status == null)
? jobRepository.findByCategoryOrderByCreatedAtDesc(category)
: jobRepository.findByCategoryAndStatusOrderByCreatedAtAsc(category, status);
}
return jobs.stream().map(JobSummary::from).toList();
}

View File

@ -2,6 +2,7 @@ package com.hlab.yanalyst.domain.shortform.dto;
import com.hlab.yanalyst.domain.shortform.ShortformClip;
import com.hlab.yanalyst.domain.shortform.ShortformJob;
import com.hlab.yanalyst.domain.shortform.ShortformCategory;
import java.time.LocalDateTime;
import java.util.List;
@ -10,18 +11,18 @@ public final class ShortformDtos {
private ShortformDtos() {}
public record RegisterRequest(String youtubeUrl) {}
public record RegisterRequest(String youtubeUrl, ShortformCategory category) {}
public record ResultRequest(String rawText, String step1Text) {}
public record ImportRequest(String youtubeUrl, String rawText, String step1Text) {}
public record ImportRequest(String youtubeUrl, String rawText, String step1Text, ShortformCategory category) {}
public record JobSummary(Long id, String youtubeUrl, String videoId, String title,
public record JobSummary(Long id, String youtubeUrl, String videoId, String title, String category,
String status, int clipCount,
LocalDateTime createdAt, LocalDateTime completedAt) {
public static JobSummary from(ShortformJob job) {
return new JobSummary(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
job.getTitle(), job.getStatus().name(), job.getClips().size(),
job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(), job.getClips().size(),
job.getCreatedAt(), job.getCompletedAt());
}
}
@ -34,12 +35,12 @@ public final class ShortformDtos {
}
}
public record JobDetail(Long id, String youtubeUrl, String videoId, String title,
public record JobDetail(Long id, String youtubeUrl, String videoId, String title, String category,
String status, LocalDateTime createdAt, LocalDateTime completedAt,
String rawOutput, String step1Output, List<ClipDto> clips) {
public static JobDetail from(ShortformJob job) {
return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
job.getTitle(), job.getStatus().name(), job.getCreatedAt(),
job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(), job.getCreatedAt(),
job.getCompletedAt(), job.getRawOutput(), job.getStep1Output(),
job.getClips().stream().map(ClipDto::from).toList());
}

View File

@ -61,6 +61,16 @@ public class WebController {
@GetMapping("/feed")
public String feed(Model model) {
model.addAttribute("currentPage", "feed");
model.addAttribute("feedTopic", "ENTERTAINMENT");
model.addAttribute("feedTitle", "소재 피드");
return "feed";
}
@GetMapping("/politics-feed")
public String politicsFeed(Model model) {
model.addAttribute("currentPage", "politics-feed");
model.addAttribute("feedTopic", "POLITICS");
model.addAttribute("feedTitle", "정치 피드");
return "feed";
}

View File

@ -3,18 +3,19 @@
layout:decorate="~{layout/base}">
<head>
<title>h-lab - 소재 피드</title>
<title th:text="'h-lab - ' + ${feedTitle}">h-lab - 소재 피드</title>
</head>
<body>
<div layout:fragment="content">
<div class="page-header">
<div>
<h1>소재 피드</h1>
<p class="sub">소재 원본(웹예능)과 경쟁 채널의 신규 롱폼(10분 이상) 업로드를 최신순으로 봅니다.</p>
<h1 th:text="${feedTitle}">소재 피드</h1>
<p class="sub" th:if="${feedTopic == 'ENTERTAINMENT'}">소재 원본(웹예능)과 경쟁 채널의 신규 롱폼 업로드를 최신순으로 봅니다.</p>
<p class="sub" th:if="${feedTopic == 'POLITICS'}">공식 야당 정치인과 정치 원본 채널의 일반 영상을 최신순으로 수집합니다.</p>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="openPersons()">
<button class="btn btn-secondary" onclick="openPersons()" th:if="${feedTopic == 'ENTERTAINMENT'}">
<i data-lucide="user-search" style="width:15px;"></i> 인물 관리
</button>
<button class="btn btn-secondary" onclick="openSeeds()">
@ -31,16 +32,16 @@
<button class="feed-tab" role="tab" id="tab-SOURCE" data-tab="SOURCE"
aria-selected="true" aria-controls="feedGrid" onclick="switchTab('SOURCE')">
<i data-lucide="film" style="width:15px;"></i>
<span>소재 원본</span>
<span class="feed-tab-hint">웹예능 롱폼</span>
<span th:text="${feedTopic == 'POLITICS'} ? '정치 원본' : '소재 원본'">소재 원본</span>
<span class="feed-tab-hint" th:text="${feedTopic == 'POLITICS'} ? '공식 정치 채널' : '웹예능 롱폼'">웹예능 롱폼</span>
</button>
<button class="feed-tab" role="tab" id="tab-RIVAL" data-tab="RIVAL"
aria-selected="false" aria-controls="feedGrid" tabindex="-1" onclick="switchTab('RIVAL')">
<i data-lucide="swords" style="width:15px;"></i>
<span>경쟁 채널</span>
<span class="feed-tab-hint">예능짤 채널</span>
<span class="feed-tab-hint" th:text="${feedTopic == 'POLITICS'} ? '정치 쇼츠 참고' : '예능짤 채널'">예능짤 채널</span>
</button>
<button class="feed-tab" role="tab" id="tab-PERSON" data-tab="PERSON"
<button class="feed-tab" role="tab" id="tab-PERSON" data-tab="PERSON" th:if="${feedTopic == 'ENTERTAINMENT'}"
aria-selected="false" aria-controls="feedGrid" tabindex="-1" onclick="switchTab('PERSON')">
<i data-lucide="user-search" style="width:15px;"></i>
<span>인물</span>
@ -101,8 +102,8 @@
</div>
<div class="modal-body">
<p class="text-sm text-muted mb-3" style="line-height:1.6;">
<b>소재 원본</b>유퀴즈·핑계고 같은 웹예능 공식채널,
<b>경쟁 채널</b>은 같은 소재를 다루는 예능짤 채널입니다. 두 역할 모두 10분 이상 롱폼만 수집합니다.
<span th:if="${feedTopic == 'ENTERTAINMENT'}"><b>소재 원본</b>은 웹예능 공식 채널, <b>경쟁 채널</b>은 같은 소재를 다루는 예능 채널입니다.</span>
<span th:if="${feedTopic == 'POLITICS'}"><b>정치 원본</b>에는 공식 정당·정치인 채널을 등록하세요. 팬 채널과 재업로드 채널은 제외하는 것이 좋습니다.</span>
</p>
<div class="seed-add">
@ -113,6 +114,13 @@
<option value="SOURCE">소재 원본</option>
<option value="RIVAL">경쟁 채널</option>
</select>
<label class="sr-only" for="seedFormat">수집 형식</label>
<select id="seedFormat">
<option value="LONG_FORM">롱폼 (10분+)</option>
<option value="VIDEO" th:selected="${feedTopic == 'POLITICS'}">일반 영상 (66초+)</option>
<option value="SHORTS">쇼츠만</option>
<option value="ALL">전체</option>
</select>
<button class="btn btn-primary" id="seedAddBtn" onclick="addSeed()">추가</button>
</div>
<p class="text-xs text-muted mt-2" id="seedAddHelp">
@ -351,6 +359,8 @@
const API = '/api/feed';
const CV_API = '/api/v1/channel-videos';
const SF_API = '/api/shortform';
const FEED_TOPIC = /*[[${feedTopic}]]*/ 'ENTERTAINMENT';
const SHORTFORM_CATEGORY = FEED_TOPIC;
let currentTab = 'SOURCE';
let items = [];
@ -426,7 +436,7 @@
}
// ---------- 탭 ----------
const TABS = ['SOURCE', 'RIVAL', 'PERSON'];
const TABS = FEED_TOPIC === 'POLITICS' ? ['SOURCE', 'RIVAL'] : ['SOURCE', 'RIVAL', 'PERSON'];
// 탭별로 의미 있는 필터만 남긴다 (소스·경쟁은 롱폼 채널 기준, 인물은 채널이 아니라 사람 기준)
function applyTabChrome(tab){
@ -509,7 +519,7 @@
const sel = document.getElementById('fProgram');
const keep = preselect || sel.value;
let list = [];
try { list = await api(`${API}/programs?tab=${currentTab}`) || []; } catch(e){ list = []; }
try { list = await api(`${API}/programs?tab=${currentTab}&topic=${FEED_TOPIC}`) || []; } catch(e){ list = []; }
sel.innerHTML = '<option value="">전체</option>' + list.map(p =>
`<option value="${esc(p.ytChannelId)}">${esc(p.channelTitle||'(이름 없음)')} (${p.videoCount})</option>`).join('');
if(keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
@ -531,6 +541,7 @@
const f = readFilters();
const p = new URLSearchParams();
p.set('tab', f.tab);
p.set('topic', FEED_TOPIC);
if(f.tab === 'PERSON'){ if(f.person) p.set('person', f.person); }
else if(f.channelId) p.set('channelId', f.channelId);
if(f.lengthBucket) p.set('lengthBucket', f.lengthBucket);
@ -578,7 +589,7 @@
? `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:14px;"></i> 원본 보기</a>`
: (inQueue
? `<a class="btn btn-secondary grow" href="/shortform">
? `<a class="btn btn-secondary grow" href="/shortform?category=${SHORTFORM_CATEGORY}">
<i data-lucide="check" style="width:14px;"></i> 큐에 있음</a>`
: `<button class="btn btn-primary grow" id="sfBtn-${it.id}"
onclick="sendToQueue(${it.id})">
@ -688,7 +699,7 @@
// 임베드가 막힌 영상이 있어 YouTube 로 여는 길을 항상 열어둔다
const inQueue = queued.has(it.videoId);
const queueBtn = currentTab === 'RIVAL' ? '' : (inQueue
? `<a class="btn btn-secondary" href="/shortform">
? `<a class="btn btn-secondary" href="/shortform?category=${SHORTFORM_CATEGORY}">
<i data-lucide="check" style="width:14px;"></i> 큐에 있음</a>`
: `<button class="btn btn-primary" onclick="sendToQueue(${it.id}); closeVideo();">
<i data-lucide="clapperboard" style="width:14px;"></i> 숏폼 큐로</button>`);
@ -715,7 +726,7 @@
/** 큐에 이미 담긴 videoId 를 받아둔다. 실패해도 피드 자체는 보여야 하므로 조용히 넘긴다. */
async function loadQueued(){
try {
const jobs = await api(SF_API + '/jobs') || [];
const jobs = await api(SF_API + '/jobs?category=' + SHORTFORM_CATEGORY) || [];
queued = new Set(jobs.map(j => j.videoId).filter(Boolean));
} catch(e){ /* 큐를 못 읽어도 피드는 정상 동작 */ }
}
@ -732,7 +743,8 @@
try {
await api(SF_API + '/jobs', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ youtubeUrl: 'https://www.youtube.com/watch?v=' + it.videoId })
body: JSON.stringify({ youtubeUrl: 'https://www.youtube.com/watch?v=' + it.videoId,
category: SHORTFORM_CATEGORY })
});
queued.add(it.videoId);
render();
@ -777,7 +789,7 @@
btn.innerHTML = '<i data-lucide="loader-2" style="width:15px;" class="animate-spin"></i> 수집 중...';
if(window.lucide) lucide.createIcons();
try {
const r = await api(API + '/collect', { method:'POST' });
const r = await api(API + '/collect?topic=' + FEED_TOPIC, { method:'POST' });
toast(`수집 완료 — 시드 ${r.seeds}개 중 ${r.collected}개, 영상 ${r.savedVideos}건`);
await loadPrograms();
await loadFeed();
@ -802,7 +814,7 @@
const box = document.getElementById('seedList');
box.innerHTML = '<div class="text-sm text-muted p-4">로딩 중...</div>';
let list = [];
try { list = await api(API + '/seeds') || []; }
try { list = await api(API + '/seeds?topic=' + FEED_TOPIC) || []; }
catch(e){ box.innerHTML = `<div class="text-sm text-danger p-4">불러오기 실패: ${esc(e.message)}</div>`; return; }
if(list.length === 0){
@ -810,7 +822,8 @@
return;
}
box.innerHTML = list.map(s => {
const roleLabel = s.role === 'SOURCE' ? '소재 원본' : '경쟁 채널';
const roleLabel = s.role === 'SOURCE'
? (FEED_TOPIC === 'POLITICS' ? '정치 원본' : '소재 원본') : '경쟁 채널';
const roleCls = s.role === 'SOURCE' ? 'badge-primary' : 'badge-muted';
const warn = s.disabled
? `<button class="warn" title="수집 실패 ${s.failCount}회로 중단됨 — 다시 활성화"
@ -821,7 +834,7 @@
<img src="${esc(s.thumbnailUrl)}" alt="" loading="lazy">
<div class="sname">
<b>${esc(s.title)}</b>
<span>구독 ${fmtNum(s.subscriberCount)}${s.disabled ? ' · 수집 중단됨' : ''}</span>
<span>구독 ${fmtNum(s.subscriberCount)} · ${s.format}${s.disabled ? ' · 수집 중단됨' : ''}</span>
</div>
<span class="badge ${roleCls}">${roleLabel}</span>
${warn}
@ -848,9 +861,10 @@
btn.disabled = true;
try {
const role = document.getElementById('seedRole').value;
const format = document.getElementById('seedFormat').value;
const s = await api(API + '/seeds', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ url, role })
body: JSON.stringify({ url, role, topic: FEED_TOPIC, format })
});
document.getElementById('seedUrl').value = '';
toast(`${s.title} 을(를) 시드로 등록했습니다`);

View File

@ -24,6 +24,9 @@
<a th:href="@{/feed}" class="nav-item" th:classappend="${currentPage == 'feed'} ? 'active'">
<i data-lucide="rss" class="nav-icon"></i><span class="nav-text">소재 피드</span>
</a>
<a th:href="@{/politics-feed}" class="nav-item" th:classappend="${currentPage == 'politics-feed'} ? 'active'">
<i data-lucide="landmark" class="nav-icon"></i><span class="nav-text">정치 피드</span>
</a>
<a th:href="@{/shortform}" class="nav-item" th:classappend="${currentPage == 'shortform'} ? 'active'">
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">숏폼 큐</span>
</a>

View File

@ -7,6 +7,12 @@
<style>
.sf-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.sf-form input { flex: 1; }
.sf-tabs { display:flex; gap:.35rem; border-bottom:1px solid var(--border); margin-bottom:1rem; }
.sf-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--text-2); padding:.7rem 1rem; font:600 .9rem/1 inherit; cursor:pointer; }
.sf-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.sf-tab:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; border-radius:var(--r-sm); }
.sf-category { font-size:.68rem; padding:.18rem .45rem; border-radius:99px; background:var(--surface-2); color:var(--text-2); font-weight:700; }
.sf-category.POLITICS { background:rgba(68, 94, 160, .12); color:#445ea0; }
.sf-paste { display: none; margin-bottom: 1rem; }
.sf-paste.open { display: block; }
.sf-paste textarea { width: 100%; min-height: 160px; font-family: monospace; font-size: 0.8rem; }
@ -48,6 +54,13 @@
<button class="btn btn-primary" type="submit">큐에 등록</button>
</form>
<div class="sf-tabs" role="tablist" aria-label="콘텐츠 분류">
<button class="sf-tab active" id="sfTab-ENTERTAINMENT" role="tab" aria-selected="true"
onclick="switchCategory('ENTERTAINMENT')">예능</button>
<button class="sf-tab" id="sfTab-POLITICS" role="tab" aria-selected="false"
onclick="switchCategory('POLITICS')">정치</button>
</div>
<div class="sf-paste" id="pasteBox">
<input type="text" id="pasteUrl" placeholder="영상 URL" style="margin-bottom:0.5rem;width:100%;">
<textarea id="pasteRaw" placeholder="Opal Output 전체를 붙여넣으세요 (1===== ... 5===== 전부)"></textarea>
@ -59,6 +72,9 @@
<th:block layout:fragment="script">
<script>
let currentCategory = new URLSearchParams(location.search).get('category') === 'POLITICS'
? 'POLITICS' : 'ENTERTAINMENT';
async function api(path, opts) {
const res = await fetch(path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts));
const body = await res.json();
@ -70,11 +86,25 @@
document.getElementById('pasteBox').classList.toggle('open');
}
function switchCategory(category) {
currentCategory = category;
document.querySelectorAll('.sf-tab').forEach(tab => {
const active = tab.id === 'sfTab-' + category;
tab.classList.toggle('active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
const q = new URLSearchParams(location.search);
q.set('category', category);
history.replaceState(null, '', location.pathname + '?' + q.toString());
loadJobs();
}
async function registerJob(e) {
e.preventDefault();
const youtubeUrl = document.getElementById('urlInput').value.trim();
try {
await api('/api/shortform/jobs', { method: 'POST', body: JSON.stringify({ youtubeUrl }) });
await api('/api/shortform/jobs', { method: 'POST',
body: JSON.stringify({ youtubeUrl, category: currentCategory }) });
document.getElementById('urlInput').value = '';
loadJobs();
} catch (err) { alert(err.message); }
@ -85,7 +115,8 @@
const rawText = document.getElementById('pasteRaw').value;
if (!youtubeUrl || !rawText.trim()) { alert('URL과 결과 원문을 모두 입력하세요'); return; }
try {
await api('/api/shortform/import', { method: 'POST', body: JSON.stringify({ youtubeUrl, rawText }) });
await api('/api/shortform/import', { method: 'POST',
body: JSON.stringify({ youtubeUrl, rawText, category: currentCategory }) });
document.getElementById('pasteUrl').value = '';
document.getElementById('pasteRaw').value = '';
togglePaste();
@ -164,7 +195,7 @@
}
async function loadJobs() {
const jobs = await api('/api/shortform/jobs');
const jobs = await api('/api/shortform/jobs?category=' + currentCategory);
const list = document.getElementById('jobList');
list.innerHTML = jobs.length ? '' : '<p style="color:var(--text-3);">등록된 작업이 없습니다.</p>';
jobs.forEach(j => {
@ -176,6 +207,7 @@
<div class="t">${esc(j.title || j.videoId)}</div>
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
</div>
<span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span>
<span class="sf-badge ${j.status}">${j.status}</span>
${j.status === 'PENDING' ? '' : `
<button class="btn btn-secondary" onclick="resetJob(${j.id}, event)"
@ -192,7 +224,7 @@
if (window.lucide) lucide.createIcons();
}
loadJobs();
switchCategory(currentCategory);
</script>
</th:block>
</body>

View File

@ -0,0 +1,26 @@
package com.hlab.yanalyst.domain.channel;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class FeedClassificationTest {
@Test
void legacyValuesDefaultToEntertainmentLongForm() {
Channel channel = Channel.builder().channelId("UC-test").title("test").build();
assertThat(channel.feedTopicOrDefault()).isEqualTo(FeedTopic.ENTERTAINMENT);
assertThat(channel.feedFormatOrDefault()).isEqualTo(FeedFormat.LONG_FORM);
}
@Test
void politicsSeedCanCollectRegularVideosWithoutShorts() {
Channel channel = Channel.builder().channelId("UC-politics").title("politics").build();
channel.configureFeed("POLITICS", "VIDEO");
assertThat(channel.feedTopicOrDefault()).isEqualTo(FeedTopic.POLITICS);
assertThat(channel.feedFormatOrDefault().minDurationSec()).isEqualTo(66);
assertThat(channel.feedFormatOrDefault().maxDurationSec()).isNull();
}
}

View File

@ -0,0 +1,23 @@
package com.hlab.yanalyst.domain.shortform;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ShortformCategoryTest {
@Test
void legacyRegistrationDefaultsToEntertainment() {
ShortformJob job = ShortformJob.create("https://youtu.be/abc12345678", "abc12345678");
assertThat(job.categoryOrDefault()).isEqualTo(ShortformCategory.ENTERTAINMENT);
}
@Test
void politicsRegistrationKeepsCategory() {
ShortformJob job = ShortformJob.create(
"https://youtu.be/abc12345678", "abc12345678", ShortformCategory.POLITICS);
assertThat(job.categoryOrDefault()).isEqualTo(ShortformCategory.POLITICS);
}
}