diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..02b8358 --- /dev/null +++ b/AGENTS.md @@ -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//`; 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` 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 `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`. diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/Channel.java b/src/main/java/com/hlab/yanalyst/domain/channel/Channel.java index fcd9415..400fa0a 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/Channel.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/Channel.java @@ -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; } diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java index b4aafeb..40d50c0 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java @@ -293,7 +293,7 @@ public class ChannelService { private void processVideos(Channel channel, List 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 videoIds, String source, - Integer minDurationSec, LocalDateTime publishedAfter, + Integer minDurationSec, Integer maxDurationSec, LocalDateTime publishedAfter, List 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 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); } diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java index 287c36d..eb3e02a 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java @@ -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); } /** 조회수 검색 결과로부터 수집 영상을 생성한다(채널 미연결). */ diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java index 90dc484..d1718a2 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideoRepository.java @@ -33,6 +33,7 @@ public interface ChannelVideoRepository extends JpaRepository '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 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 feedPrograms(@Param("source") String source); + java.util.List feedPrograms(@Param("source") String source, @Param("topic") String topic); /** 떡상 후보: 구독자 대비 조회수 비율이 높은 Shorts (제외 처리된 것은 빼고). 피드 수집물은 제외. */ @Query("select v from ChannelVideo v where v.isShorts = true " diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java index 7973acb..1583051 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedCollectionService.java @@ -68,7 +68,14 @@ public class FeedCollectionService { /** 수동/스케줄 공용. 모든 피드 시드를 쿼터 한도 안에서 수집하고 요약을 반환한다. */ public Map collectAll() { - List seeds = channelRepository.findFeedSeeds(); + return collect(null); + } + + /** 특정 주제만 즉시 수집한다. null은 스케줄러용 전체 수집이다. */ + public Map collect(FeedTopic topic) { + List 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 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()); diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java index e59c35e..19a80fb 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedController.java @@ -29,12 +29,13 @@ public class FeedController { @Operation(summary = "피드 조회", description = "tab=SOURCE(롱폼 소재)|RIVAL(경쟁 쇼츠)|PERSON(인물 추적 롱폼). 항상 최신순.") public ApiResponse> 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> programs(@RequestParam(defaultValue = "SOURCE") String tab) { - return ApiResponse.ok(feedService.programs(tab)); + public ApiResponse> 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> collect() { - return ApiResponse.ok(feedCollectionService.collectAll()); + public ApiResponse> collect( + @RequestParam(defaultValue = "ENTERTAINMENT") String topic) { + return ApiResponse.ok(feedCollectionService.collect(FeedTopic.normalize(topic))); } // --- 시드(소스/경쟁 채널) 관리 --- @GetMapping("/seeds") @Operation(summary = "시드 목록", description = "등록된 소재 원본·경쟁 채널") - public ApiResponse> seeds() { - return ApiResponse.ok(feedService.seeds()); + public ApiResponse> seeds( + @RequestParam(defaultValue = "ENTERTAINMENT") String topic) { + return ApiResponse.ok(feedService.seeds(topic)); } @PostMapping("/seeds") @@ -96,7 +100,9 @@ public class FeedController { public ApiResponse addSeed(@RequestBody Map 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}") diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedFormat.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedFormat.java new file mode 100644 index 0000000..50304f0 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedFormat.java @@ -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; + } + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java index 45e399e..f546319 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedService.java @@ -52,15 +52,16 @@ public class FeedService { */ public List 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 feed(String tab, Integer days, String ytChannelId, + public List 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 rows = channelVideoRepository.feed( - source, publishedAfter, channelFilter, minSec, maxSec, hideWorked, + source, normalizedTopic, publishedAfter, channelFilter, minSec, maxSec, hideWorked, PageRequest.of(0, MAX_ITEMS)); List out = new ArrayList<>(rows.size()); @@ -96,21 +98,25 @@ public class FeedService { } /** 필터 드롭다운용 프로그램(채널) 목록. */ - public List programs(String tab) { + public List programs(String tab, String topic) { String source = normalizeTab(tab); + String normalizedTopic = FeedTopic.normalize(topic).name(); List 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 seeds() { + public List seeds(String topic) { + FeedTopic normalizedTopic = FeedTopic.normalize(topic); List 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); } /** 시드 해제 — 채널과 그 채널에서 수집한 피드 영상을 함께 제거한다. */ diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/FeedTopic.java b/src/main/java/com/hlab/yanalyst/domain/channel/FeedTopic.java new file mode 100644 index 0000000..69e234d --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/FeedTopic.java @@ -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; + } + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedSeedDto.java b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedSeedDto.java index b4d86bb..01c04e6 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedSeedDto.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedSeedDto.java @@ -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) {} diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformCategory.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformCategory.java new file mode 100644 index 0000000..dac2d49 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformCategory.java @@ -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; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java index 9a3df0e..29f60dd 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java @@ -20,12 +20,13 @@ public class ShortformController { @PostMapping("/jobs") public ApiResponse 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(@RequestParam(required = false) ShortformJobStatus status) { - return ApiResponse.ok(shortformService.list(status)); + public ApiResponse> 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 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") diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java index bebc5b6..28bd062 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java @@ -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 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 parsed) { this.rawOutput = raw; this.clips.clear(); diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobRepository.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobRepository.java index f2d8b14..3740b78 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobRepository.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobRepository.java @@ -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 findByVideoId(String videoId); List findAllByOrderByCreatedAtDesc(); List 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 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 findByCategoryAndStatusOrderByCreatedAtAsc( + @Param("category") ShortformCategory category, @Param("status") ShortformJobStatus status); } diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java index b2a0d01..d9f19b9 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java @@ -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 list(ShortformJobStatus status) { - List jobs = (status == null) - ? jobRepository.findAllByOrderByCreatedAtDesc() - : jobRepository.findByStatusOrderByCreatedAtAsc(status); + public List list(ShortformJobStatus status, ShortformCategory category) { + List 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(); } diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java b/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java index 420080c..0877daf 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java @@ -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 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()); } diff --git a/src/main/java/com/hlab/yanalyst/web/WebController.java b/src/main/java/com/hlab/yanalyst/web/WebController.java index 71ff038..4bc92b5 100644 --- a/src/main/java/com/hlab/yanalyst/web/WebController.java +++ b/src/main/java/com/hlab/yanalyst/web/WebController.java @@ -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"; } diff --git a/src/main/resources/templates/feed.html b/src/main/resources/templates/feed.html index 42db95e..ef61f1d 100644 --- a/src/main/resources/templates/feed.html +++ b/src/main/resources/templates/feed.html @@ -3,18 +3,19 @@ layout:decorate="~{layout/base}"> - h-lab - 소재 피드 + h-lab - 소재 피드