Baseline before video model consolidation

This commit is contained in:
hehih 2026-05-30 18:56:21 +09:00
commit da04dbe15c
119 changed files with 20105 additions and 0 deletions

View File

@ -0,0 +1 @@
{"sessionId":"e9f3e0a1-1f0f-4bb8-ac46-7ffa8167e897","pid":110816,"procStart":"639156613729251550","acquiredAt":1780035816876}

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
# Build
/build/
/.gradle/
bin/
out/
# IDE
.idea/
*.iml
.vscode/
# Secrets (DO NOT COMMIT)
src/main/resources/credentials.json
tokens/
*.log
# OS
.DS_Store
Thumbs.db

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,71 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e9]
- navigation [ref=e11]:
- list [ref=e12]:
- listitem [ref=e13]:
- link [ref=e14] [cursor=pointer]:
- /url: /
- img [ref=e15]
- listitem [ref=e20]:
- link [ref=e21] [cursor=pointer]:
- /url: /channels
- img [ref=e22]
- listitem [ref=e27]:
- link [ref=e28] [cursor=pointer]:
- /url: /videos
- img [ref=e29]
- listitem [ref=e32]:
- link [ref=e33] [cursor=pointer]:
- /url: /collection
- img [ref=e34]
- listitem [ref=e36]:
- link [ref=e37] [cursor=pointer]:
- /url: /production
- img [ref=e38]
- listitem [ref=e43]:
- link [ref=e44] [cursor=pointer]:
- /url: "#"
- img [ref=e45]
- button [ref=e51]:
- img [ref=e52]
- main [ref=e55]:
- generic [ref=e56]:
- generic [ref=e57]:
- heading "Dashboard" [level=1] [ref=e58]
- paragraph [ref=e59]: Overview of your YouTube analytics and tracking.
- generic [ref=e60]:
- generic [ref=e61]:
- generic [ref=e62]:
- generic [ref=e63]:
- paragraph [ref=e64]: 수집 영상
- heading "-" [level=3] [ref=e65]
- img [ref=e67]
- generic [ref=e69]: 채널 + 검색 수집
- generic [ref=e70]:
- generic [ref=e71]:
- generic [ref=e72]:
- paragraph [ref=e73]: 작업대상 (TARGET)
- heading "-" [level=3] [ref=e74]
- img [ref=e76]
- generic [ref=e81]: 검토중 -
- generic [ref=e82]:
- generic [ref=e83]:
- generic [ref=e84]:
- paragraph [ref=e85]: 미검토 (NEW)
- heading "-" [level=3] [ref=e86]
- img [ref=e88]
- generic [ref=e91]: 제외 -
- generic [ref=e92]:
- generic [ref=e93]:
- generic [ref=e94]:
- heading "🚀 떡상 후보 TOP" [level=3] [ref=e95]
- link "수집함 →" [ref=e96] [cursor=pointer]:
- /url: /collection
- paragraph [ref=e98]: 로딩 중...
- generic [ref=e99]:
- heading "수집 출처" [level=3] [ref=e100]
- paragraph [ref=e102]: 로딩 중...

View File

@ -0,0 +1,107 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e9]
- navigation [ref=e11]:
- list [ref=e12]:
- listitem [ref=e13]:
- link [ref=e14] [cursor=pointer]:
- /url: /
- img [ref=e15]
- listitem [ref=e20]:
- link [ref=e21] [cursor=pointer]:
- /url: /channels
- img [ref=e22]
- listitem [ref=e27]:
- link [ref=e28] [cursor=pointer]:
- /url: /videos
- img [ref=e29]
- listitem [ref=e32]:
- link [ref=e33] [cursor=pointer]:
- /url: /collection
- img [ref=e34]
- listitem [ref=e36]:
- link [ref=e37] [cursor=pointer]:
- /url: /production
- img [ref=e38]
- listitem [ref=e43]:
- link [ref=e44] [cursor=pointer]:
- /url: "#"
- img [ref=e45]
- button [ref=e51]:
- img [ref=e52]
- main [ref=e55]:
- generic [ref=e56]:
- generic [ref=e57]:
- heading "YouTube Video Search" [level=1] [ref=e58]
- paragraph [ref=e59]: Search YouTube videos using YouTube Data API.
- generic [ref=e61]:
- generic [ref=e62]:
- generic [ref=e63]:
- generic [ref=e64]: Keyword
- textbox "Search keyword..." [ref=e65]
- generic [ref=e66]:
- generic [ref=e67]: Country
- generic [ref=e68]:
- generic [ref=e69]:
- checkbox "JP" [checked] [ref=e70]
- text: JP
- generic [ref=e71]:
- checkbox "US" [checked] [ref=e72]
- text: US
- generic [ref=e73]:
- checkbox "KR" [ref=e74]
- text: KR
- generic [ref=e75]:
- generic [ref=e76]: Period
- combobox [ref=e77]:
- option "Within 1 Day" [selected]
- option "Within 7 Days"
- option "Within 10 Days"
- option "Within 15 Days"
- option "Within 30 Days"
- option "전부 (All)"
- generic [ref=e78]:
- generic [ref=e79]: Format
- generic [ref=e80]:
- generic [ref=e81]:
- radio "Shorts" [checked] [ref=e82]
- text: Shorts
- generic [ref=e83]:
- radio "Long" [ref=e84]
- text: Long
- generic [ref=e85]:
- generic [ref=e86]: Load Size
- combobox [ref=e87]:
- option "20 items"
- option "50 items" [selected]
- option "100 items"
- button "Search" [ref=e89] [cursor=pointer]:
- img [ref=e90]
- generic [ref=e93]: Search
- generic [ref=e95]:
- button "선택 담기" [ref=e96] [cursor=pointer]:
- img [ref=e97]
- text: 선택 담기
- button "전체 담기" [ref=e100] [cursor=pointer]:
- img [ref=e101]
- text: 전체 담기
- table [ref=e105]:
- rowgroup [ref=e106]:
- row "전체 선택 Thumbnail Title Channel Publish Date Performance ▼ Views Subscribers" [ref=e107]:
- columnheader "전체 선택" [ref=e108]:
- checkbox "전체 선택" [ref=e109]
- columnheader "Thumbnail" [ref=e110]
- columnheader "Title" [ref=e111]
- columnheader "Channel" [ref=e112]
- columnheader "Publish Date" [ref=e113]: Publish Date
- columnheader "Performance ▼" [ref=e114]:
- text: Performance
- generic [ref=e115]:
- columnheader "Views" [ref=e116]: Views
- columnheader "Subscribers" [ref=e117]: Subscribers
- rowgroup [ref=e118]:
- row "Enter search conditions and click Search." [ref=e119]:
- cell "Enter search conditions and click Search." [ref=e120]

View File

@ -0,0 +1,46 @@
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e4]:
- generic [ref=e7]: H
- button [ref=e8] [cursor=pointer]:
- img [ref=e9]
- navigation [ref=e11]:
- list [ref=e12]:
- listitem [ref=e13]:
- link [ref=e14] [cursor=pointer]:
- /url: /
- img [ref=e15]
- listitem [ref=e20]:
- link [ref=e21] [cursor=pointer]:
- /url: /channels
- img [ref=e22]
- listitem [ref=e27]:
- link [ref=e28] [cursor=pointer]:
- /url: /videos
- img [ref=e29]
- listitem [ref=e32]:
- link [ref=e33] [cursor=pointer]:
- /url: /collection
- img [ref=e34]
- listitem [ref=e36]:
- link [ref=e37] [cursor=pointer]:
- /url: /board
- img [ref=e38]
- listitem [ref=e40]:
- link [ref=e41] [cursor=pointer]:
- /url: /production
- img [ref=e42]
- listitem [ref=e47]:
- link [ref=e48] [cursor=pointer]:
- /url: "#"
- img [ref=e49]
- button [ref=e55]:
- img [ref=e56]
- main [ref=e59]:
- generic [ref=e61]:
- generic [ref=e62]:
- heading "작업 보드" [level=1] [ref=e63]
- paragraph [ref=e64]: 카드를 드래그해 단계를 옮기세요. 수집 → 검토 → 작업대상 → 완료.
- button "새로고침" [ref=e65] [cursor=pointer]:
- img [ref=e66]
- text: 새로고침

File diff suppressed because it is too large Load Diff

72
CLAUDE.md Normal file
View File

@ -0,0 +1,72 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 개요
h-lab(아티팩트명 `yanalyst`)은 유튜브 데이터를 수집·분석하고, 이를 바탕으로 다단계 콘텐츠 제작 파이프라인을 구동하는 개인용 웹 서비스입니다. Spring Boot 3.4.0 / Java 21 백엔드에 **서버 사이드 렌더링(Thymeleaf)** UI를 사용합니다 (README.md에 언급된 React/Vite 프론트엔드는 SSR로 대체되어 제거됨). 모든 UI는 동일한 Spring Boot 앱에서 제공됩니다.
## 명령어
Windows에서는 `gradlew.bat`을 사용합니다. Gradle wrapper는 저장소 루트에 있습니다 (README.md에는 `backend/` 디렉토리가 있다고 되어 있으나 실제로는 없음).
```powershell
# 앱 실행 (UI + API를 http://localhost:8088 에서 제공)
.\gradlew.bat bootRun
# 빌드
.\gradlew.bat build
# 테스트 실행
.\gradlew.bat test
# 단일 테스트 클래스 / 메서드 실행
.\gradlew.bat test --tests "com.hlab.yanalyst.SomeTest"
.\gradlew.bat test --tests "com.hlab.yanalyst.SomeTest.someMethod"
```
- **서버 포트는 8088** (`application.yml`)입니다. README의 8080이 아닙니다. Swagger UI: `http://localhost:8088/swagger-ui.html`.
- 현재 **`src/test` 디렉토리가 없습니다** — `.\gradlew.bat test`는 사실상 빈 상태로 통과합니다. 테스트는 `src/test/java/com/hlab/yanalyst/` 아래에 추가하세요.
## 아키텍처
### 두 가지 패키지 컨벤션이 공존 (중요)
코드베이스에 두 가지 구조 스타일이 섞여 있습니다. 코드를 추가하기 전에 어느 쪽인지 파악하세요:
1. **`domain/<aggregate>/`** — DDD 스타일 패키지 (`channel`, `video`, `opal`, `production`, `script`). 각 패키지가 Entity + Repository + Service + `@RestController`(Swagger 문서화, `/api/v1/...` 경로) + `dto/`를 함께 묶습니다.
2. **`web/`** + **`service/`** — 이후에 추가된 두 번째 레이어. `web/`에는 Thymeleaf 페이지 컨트롤러(`WebController`, `ChannelDetailController`)와 추가 JSON `@RestController`들(`/api/...` 경로, `v1` 없음)이 있습니다. `service/`에는 횡단 관심사인 `AnalysisWorkflowService``YtVideoService`가 있습니다.
**video 엔티티가 두 개**라는 점에 유의: `domain/video/Video.java`(`/api/v1/videos`) vs `domain/video/YtVideo.java`(테이블 `yt_video`, `web/YtVideoController` + `web/VideoActionController`를 통해 `/api/videos`에서 구동). 실제 콘텐츠 제작 워크플로우는 **`YtVideo`** 기반으로 동작하며, `Video`는 구버전 읽기 모델입니다. 기능을 수정하기 전에 어느 쪽을 대상으로 하는지 확인하세요.
### Opal 콘텐츠 제작 워크플로우
`service/AnalysisWorkflowService`가 오케스트레이터이며 앱의 핵심입니다. 이 파이프라인은 `YtVideo.status` 필드를 `CRAWLED → SCRIPT_READY → DRAFTING → FINALIZED` 순으로 진행시킵니다. 단계:
1. **generateScript** → 트랜스크립트를 가져와 `ScriptGen` 저장 (비디오당 1개, PK = videoId).
2. **generateDraft** → 버전이 매겨진 `OpalDraft` 행 생성 (비디오별 `versionNo` 자동 증가), 사용자 피드백을 반영 가능.
3. **acceptDraft** → draft를 활성 `OpalFinal`로 승격 (비디오당 `isActive=true`는 하나만 유지, 이전 것은 비활성화).
4. **generateFinalAsset** → 최종 스크립트를 가져와 `OpalFinalAsset` 생성 (title/summary/timeline/video_prompt/image_urls를 JSON 컬럼으로 저장).
`web/VideoActionController`를 통해 구동됩니다 (`POST /api/videos/{id}/script|drafts|final-asset` 등).
### 외부 연동 (`service/external/`)
`ExternalApiService`는 인터페이스이며 구현체가 두 개입니다:
- **`ExternalApiServiceImpl`** (`@Primary`) — 실제 구현. Python 트랜스크립트 마이크로서비스(`http://h-python.tolag.shop/transcript`)를 호출하고, Google Docs API로 **Google Docs**를 읽고 비웁니다. Google Doc ID는 모드별(`TRUE_STORY` vs `STRUCTURE_CHANGE`) 및 워크플로우 단계별로 하드코딩되어 있습니다. `generateScript`는 실패 시 예외를 던지지 않고 fallback 플레이스홀더 트랜스크립트로 degrade하며, `generateFinalAsset`은 아직 플레이스홀더 데이터를 반환하는 스텁입니다.
- **`ExternalApiServiceStub`** — fallback/테스트용 더미.
Google Docs 인증은 OAuth installed-app 플로우를 사용합니다: 클라이언트 시크릿은 `src/main/resources/credentials.json`, 리프레시 토큰은 `tokens/` 디렉토리(`StoredCredential`)에 캐싱됩니다. 최초 실행 시 포트 8888에서 브라우저를 열어 인증을 진행합니다. 이 구현체는 **읽은 후 원본 Google Doc의 내용을 삭제합니다**(`clearGoogleDoc`) — 테스트 시 파괴적이므로 주의하세요.
`production/` 도메인은 랭킹/크롤 데이터를 위해 외부 **n8n webhook**과 연동합니다.
### 컨벤션
- **영속성**: PostgreSQL(원격, `application.yml`에 설정), JPA `ddl-auto: update` — 스키마가 엔티티에서 자동 관리되며 마이그레이션 파일은 없습니다. Lombok 전면 사용, `@EnableJpaAuditing``@CreationTimestamp`/`@UpdateTimestamp`. p6spy가 `global/config/P6SpyFormatter`로 포맷된 SQL을 로깅합니다.
- **API 응답**: JSON 결과는 `global/common/ApiResponse<T>`로 감쌉니다 (`ApiResponse.ok(...)` / `.created(...)` / `.error(...)`). 에러는 `global/error/GlobalExceptionHandler`에서 중앙 처리됩니다.
- **Thymeleaf**: 템플릿은 `src/main/resources/templates/`에 있고, 공유 `layout/base.html` + `layout/sidebar.html`을 사용합니다(thymeleaf-layout-dialect). 페이지 컨트롤러는 사이드바 하이라이트를 위해 `currentPage` 모델 속성을 설정합니다. 정적 CSS/JS는 `static/`에 있으며 다크모드 디자인은 `variables.css` 기반입니다.
- CORS는 `global/config/WebMvcConfig`에서 전면 개방되어 있습니다 (`allowedOriginPatterns("*")`, credentials 허용).
## 보안 참고
`src/main/resources/application.yml`에 PostgreSQL 비밀번호가 하드코딩되어 있고, `credentials.json` / `tokens/StoredCredential`에 Google OAuth 시크릿이 저장되어 저장소에 커밋되어 있습니다. 이 값들을 로그나 외부 서비스에 노출하지 말고, 추가 시크릿을 커밋하라는 요청이 있으면 경고하세요.

50
README.md Normal file
View File

@ -0,0 +1,50 @@
# h-lab
유튜브 데이터를 수집하고 분석하는 개인용 웹 서비스입니다.
Spring Boot 4.x(3.4.0) 기반의 백엔드와 React + Vite 기반의 프론트엔드로 구성되어 있습니다.
## 🛠 Tech Stack
### Backend
- **Framework**: Spring Boot 3.4.0 (Java 21)
- **Database**: H2 (Development), JPA
- **API Docs**: Swagger UI (SpringDoc)
- **Architecture**: Domain-Driven Design style
### Frontend
- **Framework**: React 18 (Vite)
- **Language**: TypeScript
- **Styling**: Vanilla CSS + CSS Variables (Dark Mode)
- **State**: Zustand
- **Charts**: Recharts
## 🚀 How to Run
### Backend & Frontend (Integrated)
1. `backend` 디렉토리로 이동
2. `./gradlew bootRun` 실행 (Windows: `gradlew bootRun`)
3. 웹 서비스 접속: `http://localhost:8080`
- 메인 대시보드: `http://localhost:8080/`
- 채널 관리: `http://localhost:8080/channels`
- Swagger UI: `http://localhost:8080/swagger-ui.html`
### Frontend (Legacy React)
*Removed in favor of Thymeleaf Server-Side Rendering*
## 📂 Project Structure
```
h-lab/
├── src/main/java/com/hlab/yanalyst/
│ ├── domain/ # Domain Entities (Video, Channel...)
│ └── web/ # Web Controllers (Thymeleaf)
└── src/main/resources/
├── static/ # CSS, JS
└── templates/ # HTML Templates (Thymeleaf)
```
## ✨ Key Features
- **Dashboard**: 전체 데이터 요약 및 시각화 (Charts)
- **Channel Management**: 분석 대상 유튜브 채널 관리
- **Dark Mode**: 눈이 편안한 프리미엄 다크 모드 UI

51
build.gradle Normal file
View File

@ -0,0 +1,51 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.0' // Assuming 3.4.0 as latest stable for now (User asked for 4.x but 3.4 is realistic latest acting as next-gen)
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.hlab'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
// Swagger (SpringDoc) 2.7.0+ is required for Spring Boot 3.4 (2.3.0 throws NoSuchMethodError on /v3/api-docs)
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.9.0'
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2'
// Google Docs API
implementation 'com.google.api-client:google-api-client:2.0.0'
implementation 'com.google.oauth-client:google-oauth-client-jetty:1.34.1'
implementation 'com.google.apis:google-api-services-docs:v1-rev20220609-2.0.0'
}
tasks.named('test') {
useJUnitPlatform()
}

BIN
build_log.txt Normal file

Binary file not shown.

BIN
build_log_final.txt Normal file

Binary file not shown.

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Normal file
View File

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
settings.gradle Normal file
View File

@ -0,0 +1 @@
rootProject.name = 'h-lab'

View File

@ -0,0 +1,17 @@
package com.hlab.yanalyst;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableJpaAuditing
@EnableScheduling
public class HLabApplication {
public static void main(String[] args) {
SpringApplication.run(HLabApplication.class, args);
}
}

View File

@ -0,0 +1,58 @@
package com.hlab.yanalyst.domain.category;
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 = "categories")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 100)
private String name;
/** UI 표시용 색상 (#RRGGBB). 선택값. */
@Column(length = 20)
private String color;
@Column(columnDefinition = "TEXT")
private String description;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@Builder
public Category(String name, String color, String description) {
this.name = name;
this.color = color;
this.description = description;
}
public void update(String name, String color, String description) {
this.name = name;
this.color = color;
this.description = description;
}
}

View File

@ -0,0 +1,48 @@
package com.hlab.yanalyst.domain.category;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/categories")
@RequiredArgsConstructor
@Tag(name = "Category API", description = "수집 영상 분류용 카테고리 관리")
public class CategoryController {
private final CategoryService categoryService;
@GetMapping
@Operation(summary = "카테고리 목록", description = "모든 카테고리를 이름순으로 조회한다.")
public ApiResponse<List<Category>> list() {
return ApiResponse.ok(categoryService.getAll());
}
@PostMapping
@Operation(summary = "카테고리 생성", description = "name(필수), color(#RRGGBB), description")
public ApiResponse<Category> create(@RequestBody Map<String, String> body) {
Category category = categoryService.create(
body.get("name"), body.get("color"), body.get("description"));
return ApiResponse.created(category);
}
@PutMapping("/{id}")
@Operation(summary = "카테고리 수정")
public ApiResponse<Category> update(@PathVariable Long id, @RequestBody Map<String, String> body) {
Category category = categoryService.update(
id, body.get("name"), body.get("color"), body.get("description"));
return ApiResponse.ok(category);
}
@DeleteMapping("/{id}")
@Operation(summary = "카테고리 삭제", description = "삭제 시 해당 분류가 걸린 영상들의 분류는 해제된다.")
public ApiResponse<Void> delete(@PathVariable Long id) {
categoryService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,10 @@
package com.hlab.yanalyst.domain.category;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface CategoryRepository extends JpaRepository<Category, Long> {
Optional<Category> findByName(String name);
boolean existsByName(String name);
}

View File

@ -0,0 +1,66 @@
package com.hlab.yanalyst.domain.category;
import com.hlab.yanalyst.domain.channel.ChannelVideo;
import com.hlab.yanalyst.domain.channel.ChannelVideoRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CategoryService {
private final CategoryRepository categoryRepository;
private final ChannelVideoRepository channelVideoRepository;
public List<Category> getAll() {
return categoryRepository.findAll(Sort.by(Sort.Direction.ASC, "name"));
}
public Category get(Long id) {
return categoryRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Category not found: " + id));
}
@Transactional
public Category create(String name, String color, String description) {
if (categoryRepository.existsByName(name)) {
throw new IllegalArgumentException("이미 존재하는 카테고리입니다: " + name);
}
return categoryRepository.save(Category.builder()
.name(name)
.color(color)
.description(description)
.build());
}
@Transactional
public Category update(Long id, String name, String color, String description) {
Category category = get(id);
// 다른 카테고리가 같은 이름을 쓰고 있으면 거부
categoryRepository.findByName(name)
.filter(c -> !c.getId().equals(id))
.ifPresent(c -> { throw new IllegalArgumentException("이미 존재하는 카테고리 이름입니다: " + name); });
category.update(name, color, description);
return category;
}
@Transactional
public void delete(Long id) {
// 해당 카테고리로 분류된 영상들의 분류를 해제(고아 참조 방지)
List<ChannelVideo> videos = channelVideoRepository.findByCategoryId(id);
for (ChannelVideo video : videos) {
video.assignCategory(null);
channelVideoRepository.save(video);
}
categoryRepository.deleteById(id);
}
public long countVideos(Long categoryId) {
return channelVideoRepository.countByCategoryId(categoryId);
}
}

View File

@ -0,0 +1,82 @@
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;
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;
}
// Domain Logic methods here if needed
}

View File

@ -0,0 +1,45 @@
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/channels")
@RequiredArgsConstructor
@Tag(name = "Channel API", description = "Channel Management API")
public class ChannelController {
private final ChannelService channelService;
@GetMapping
@Operation(summary = "Get all channels", description = "Retrieve a list of all channels.")
public ApiResponse<List<Channel>> getChannels() {
return ApiResponse.ok(channelService.getAllChannels());
}
@GetMapping("/{id}")
@Operation(summary = "Get channel details", description = "Retrieve detailed information of a specific channel.")
public ApiResponse<Channel> getChannel(@PathVariable Long id) {
return ApiResponse.ok(channelService.getChannel(id));
}
@GetMapping("/{id}/growth")
@Operation(summary = "채널 성장 추이", description = "일별 구독자/조회수/영상수 스냅샷(오래된 순).")
public ApiResponse<List<ChannelSnapshot>> getGrowth(@PathVariable Long id) {
return ApiResponse.ok(channelService.getGrowth(id));
}
@org.springframework.web.bind.annotation.PostMapping("/{id}/snapshot")
@Operation(summary = "채널 통계 갱신 + 스냅샷 기록", description = "YouTube에서 현재 통계를 다시 받아와 오늘자 성장 스냅샷을 남긴다.")
public ApiResponse<Channel> snapshot(@PathVariable Long id) {
return ApiResponse.ok(channelService.refreshChannelStats(id));
}
}

View File

@ -0,0 +1,9 @@
package com.hlab.yanalyst.domain.channel;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ChannelRepository extends JpaRepository<Channel, Long> {
Optional<Channel> findByChannelId(String channelId);
boolean existsByChannelId(String channelId);
}

View File

@ -0,0 +1,391 @@
package com.hlab.yanalyst.domain.channel;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ChannelService {
private final ChannelRepository channelRepository;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final ChannelVideoRepository channelVideoRepository;
private final ChannelVideoScriptRepository channelVideoScriptRepository;
private final ChannelSnapshotRepository channelSnapshotRepository;
@Value("${youtube.api.key}") // application.yml(youtube.api.key) 환경변수 YOUTUBE_API_KEY 오버라이드
private String youtubeApiKey;
@Transactional
public Channel saveChannelFromUrl(String url) {
String identifier = extractIdentifier(url);
boolean isHandle = url.contains("@");
String apiUrl = "https://www.googleapis.com/youtube/v3/channels";
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(apiUrl)
.queryParam("part", "snippet,statistics,contentDetails")
.queryParam("key", youtubeApiKey);
if (isHandle) {
builder.queryParam("forHandle", identifier);
} else {
builder.queryParam("id", identifier);
}
try {
JsonNode root = restTemplate.getForObject(builder.toUriString(), JsonNode.class);
JsonNode items = root.path("items");
if (items.isEmpty()) {
throw new IllegalArgumentException("Channel not found for identifier: " + identifier);
}
JsonNode item = items.get(0);
String channelId = item.get("id").asText();
JsonNode snippet = item.get("snippet");
JsonNode statistics = item.get("statistics");
JsonNode contentDetails = item.get("contentDetails");
String title = snippet.get("title").asText();
String description = snippet.get("description").asText();
String thumbnailUrl = snippet.get("thumbnails").get("high").get("url").asText();
String publishedAtStr = snippet.get("publishedAt").asText(); // ISO 8601
LocalDateTime publishedAt = LocalDateTime.parse(publishedAtStr, DateTimeFormatter.ISO_DATE_TIME);
Long viewCount = Long.parseLong(statistics.get("viewCount").asText());
Long subscriberCount = Long.parseLong(statistics.get("subscriberCount").asText());
Long videoCount = Long.parseLong(statistics.get("videoCount").asText());
String uploadsPlaylistId = contentDetails.path("relatedPlaylists").path("uploads").asText();
Channel channel = channelRepository.findByChannelId(channelId)
.map(existingChannel -> {
existingChannel.update(title, description, thumbnailUrl, subscriberCount, viewCount, videoCount);
existingChannel.setUploadsPlaylistId(uploadsPlaylistId);
return existingChannel;
})
.orElseGet(() -> Channel.builder()
.channelId(channelId)
.title(title)
.description(description)
.thumbnailUrl(thumbnailUrl)
.subscriberCount(subscriberCount)
.viewCount(viewCount)
.videoCount(videoCount)
.publishedAt(publishedAt)
.uploadsPlaylistId(uploadsPlaylistId)
.build());
// Reflected updates for new field if setters are not available in update method yet
// Assuming setter or reflection, but we added uploadsPlaylistId field.
// Better to update entity directly if update method doesn't cover it.
// Since we didn't add uploadsPlaylistId to update() method on Channel entity yet, we might miss it on update.
// However, we can use reflection or add a method. For now let's rely on JPA saving the new field if it's new.
// Wait, for existing entity, we need to set it.
// Let's assume we can modify the entity logic or just set it via field access if public/setter.
// Actually, we should've added it to update method.
// Let's use a direct field set via reflection or just ignore if it's not critical for now, BUT it IS critical.
// I will forcefully set it via a new method or assume I can add a setter in next step if needed.
// Ah, I missed adding it to update(). I will use a custom repository method or simple save.
// Actually, I can just modify the update logic here slightly if I had setters.
// Since Channel is @Getter and no Setters (except update method), I should have updated the update method.
// I will fix Channel.java's update method later or adding a setter.
// For now, let's proceed and I'll add a 'setUploadsPlaylistId' to Channel entity in a separate tool call if needed or just use what I have.
// Wait, looking at Channel.java, it has NO SETTERS.
// I MUST update Channel.java to have a method to set this ID, OR update existing `update` method.
// I will do that in a separate step. For now, let's persist.
// To make sure it saves, I'll invoke a direct SQL update or just rely on 'save' for new ones.
// For existing, it won't be updated. This is a BUG in my plan.
// Corrective action: I'll add a setter for uploadsPlaylistId in Channel.java FIRST.
// ... (rest of logic)
// But wait, I can't break the build.
// Let's implement the rest of the service methods.
Channel saved = channelRepository.save(channel);
captureSnapshot(saved); // 성장 추이용 일별 스냅샷 기록(upsert)
return saved;
} catch (Exception e) {
log.error("Failed to fetch channel info for URL: {}", url, e);
throw new RuntimeException("Failed to fetch channel info", e);
}
}
/** 채널 통계 스냅샷을 오늘 날짜로 upsert. */
private void captureSnapshot(Channel channel) {
java.time.LocalDate today = java.time.LocalDate.now();
channelSnapshotRepository.findByChannelIdAndSnapshotDate(channel.getId(), today)
.ifPresentOrElse(
s -> s.update(channel.getSubscriberCount(), channel.getViewCount(), channel.getVideoCount()),
() -> channelSnapshotRepository.save(new ChannelSnapshot(
channel.getId(), today,
channel.getSubscriberCount(), channel.getViewCount(), channel.getVideoCount())));
}
/** 채널 통계를 YouTube 에서 다시 받아와 갱신하고 스냅샷을 기록한다. */
@Transactional
public Channel refreshChannelStats(Long channelId) {
Channel c = getChannel(channelId);
return saveChannelFromUrl("https://www.youtube.com/channel/" + c.getChannelId());
}
/** 채널 성장 추이(일별 스냅샷, 오래된 순). */
public List<ChannelSnapshot> getGrowth(Long channelId) {
return channelSnapshotRepository.findByChannelIdOrderBySnapshotDateAsc(channelId);
}
private String extractIdentifier(String url) {
if (url.contains("youtube.com/")) {
if (url.contains("@")) {
String handle = url.substring(url.indexOf("@"));
try {
return java.net.URLDecoder.decode(handle, java.nio.charset.StandardCharsets.UTF_8);
} catch (Exception e) {
return handle;
}
} else if (url.contains("/channel/")) {
String[] parts = url.split("/channel/");
if (parts.length > 1) {
return parts[1].split("/")[0].split("\\?")[0];
}
}
}
return url;
}
public List<Channel> getAllChannels() {
return channelRepository.findAll();
}
public Channel getChannel(Long id) {
return channelRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Channel not found with id: " + id));
}
@Transactional
public void deleteChannel(Long id) {
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(id);
for (ChannelVideo video : videos) {
channelVideoScriptRepository.findByVideoId(video.getVideoId())
.ifPresent(channelVideoScriptRepository::delete);
channelVideoRepository.delete(video);
}
channelRepository.deleteById(id);
}
@Transactional
public void collectChannelVideos(Long channelId) {
Channel channel = getChannel(channelId);
String uploadPlaylistId = channel.getUploadsPlaylistId();
if (uploadPlaylistId == null || uploadPlaylistId.isEmpty()) {
// Self-healing: try to update channel info
try {
String tempUrl = "https://www.youtube.com/channel/" + channel.getChannelId();
Channel updatedChannel = saveChannelFromUrl(tempUrl);
uploadPlaylistId = updatedChannel.getUploadsPlaylistId();
channel = updatedChannel;
} catch (Exception e) {
log.error("Failed to auto-update channel info during sync", e);
throw new IllegalArgumentException("Uploads playlist ID not found. Please re-add/update the channel.");
}
}
String nextPageToken = null;
int maxVideos = 200; // Safety limit
int currentCount = 0;
do {
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/playlistItems")
.queryParam("part", "snippet,contentDetails")
.queryParam("playlistId", uploadPlaylistId)
.queryParam("maxResults", 50)
.queryParam("key", youtubeApiKey)
.queryParamIfPresent("pageToken", java.util.Optional.ofNullable(nextPageToken))
.toUriString();
try {
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
JsonNode items = root.path("items");
nextPageToken = root.path("nextPageToken").asText(null);
List<String> videoIds = new java.util.ArrayList<>();
for (JsonNode item : items) {
String videoId = item.get("snippet").get("resourceId").get("videoId").asText();
videoIds.add(videoId);
}
if (!videoIds.isEmpty()) {
processVideos(channel, videoIds);
currentCount += videoIds.size();
}
} catch (Exception e) {
log.error("Error fetching playlist items", e);
break;
}
} while (nextPageToken != null && currentCount < maxVideos);
}
private void processVideos(Channel channel, List<String> videoIds) {
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
.queryParam("part", "snippet,statistics,contentDetails")
.queryParam("id", String.join(",", videoIds))
.queryParam("key", youtubeApiKey)
.toUriString();
try {
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
JsonNode items = root.path("items");
for (JsonNode item : items) {
String videoId = item.get("id").asText();
JsonNode snippet = item.get("snippet");
JsonNode statistics = item.get("statistics");
JsonNode contentDetails = item.get("contentDetails");
String title = snippet.get("title").asText();
String thumbnailUrl = snippet.get("thumbnails").has("maxres")
? snippet.get("thumbnails").get("maxres").get("url").asText()
: snippet.get("thumbnails").get("high").get("url").asText();
LocalDateTime publishedAt = LocalDateTime.parse(snippet.get("publishedAt").asText(), DateTimeFormatter.ISO_DATE_TIME);
Long viewCount = statistics.has("viewCount") ? Long.parseLong(statistics.get("viewCount").asText()) : 0L;
Long likeCount = statistics.has("likeCount") ? Long.parseLong(statistics.get("likeCount").asText()) : 0L;
String duration = contentDetails.get("duration").asText();
// --- 파생 분석 지표 계산 ---
Integer durationSec = VideoMetrics.parseDurationSec(duration);
Boolean isShorts = VideoMetrics.isShorts(durationSec);
java.math.BigDecimal viewsPerHour = VideoMetrics.viewsPerHour(viewCount, publishedAt);
java.math.BigDecimal viewsPerSubRatio = VideoMetrics.viewsPerSubRatio(viewCount, channel.getSubscriberCount());
String ytChannelId = channel.getChannelId();
String channelTitle = channel.getTitle();
Long subscriberCount = channel.getSubscriberCount();
channelVideoRepository.findByVideoId(videoId)
.ifPresentOrElse(v -> {
v.update(title, thumbnailUrl, viewCount, likeCount);
v.applyMetrics(durationSec, isShorts, viewsPerHour);
v.applyChannelInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio);
channelVideoRepository.save(v);
}, () -> {
ChannelVideo newVideo = ChannelVideo.builder()
.channel(channel)
.videoId(videoId)
.title(title)
.thumbnailUrl(thumbnailUrl)
.publishedAt(publishedAt)
.viewCount(viewCount)
.likeCount(likeCount)
.duration(duration)
.build();
newVideo.applyMetrics(durationSec, isShorts, viewsPerHour);
newVideo.applyChannelInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio);
channelVideoRepository.save(newVideo);
});
}
} catch (Exception e) {
log.error("Error fetching video details", e);
}
}
public List<ChannelVideo> getChannelVideos(Long channelId) {
return channelVideoRepository.findByChannelId(channelId);
}
public List<ChannelVideo> getChannelsVideos(List<Long> channelIds) {
return channelVideoRepository.findByChannelIdInOrderByPublishedAtDesc(channelIds);
}
public List<Channel> getChannelsByIds(List<Long> ids) {
return channelRepository.findAllById(ids);
}
@Transactional
public void extractScript(Long channelVideoId) {
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
String apiUrl = "http://h-python.tolag.shop/transcript";
// Construct standard YouTube URL from video ID
String videoUrl = "https://www.youtube.com/watch?v=" + video.getVideoId();
java.util.Map<String, String> requestBody = java.util.Collections.singletonMap("url", videoUrl);
log.info("Requesting transcript for URL: {}", videoUrl);
try {
org.springframework.http.ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, requestBody, String.class);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
com.hlab.yanalyst.domain.production.dto.ScriptResponseDto scriptDto =
objectMapper.readValue(response.getBody(), com.hlab.yanalyst.domain.production.dto.ScriptResponseDto.class);
ChannelVideoScript script = new ChannelVideoScript();
script.setChannelVideoId(channelVideoId);
script.setVideoId(video.getVideoId());
script.setLanguage(scriptDto.getLanguage());
script.setTranscript(scriptDto.getTranscript());
channelVideoScriptRepository.save(script);
video.setHasScript(true);
channelVideoRepository.save(video);
log.info("Saved script for channel video id: {}", channelVideoId);
} else {
log.error("Failed to fetch script. Status: {}", response.getStatusCode());
throw new RuntimeException("External API failed with status: " + response.getStatusCode());
}
} catch (Exception e) {
log.error("Error extracting script", e);
throw new RuntimeException("Error extracting script", e);
}
}
@Transactional
public void extractAllScripts(Long channelId) {
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(channelId);
int successCount = 0;
int failCount = 0;
for (ChannelVideo video : videos) {
if (!video.isHasScript()) {
try {
extractScript(video.getId());
successCount++;
// Basic rate limiting/pause to avoid overwhelming the external service if needed
// Thread.sleep(500);
} catch (Exception e) {
log.error("Failed to extract script for video: " + video.getVideoId(), e);
failCount++;
// Continue to next video even if one fails
}
}
}
log.info("Bulk extraction completed. Success: {}, Fail: {}", successCount, failCount);
}
}

View File

@ -0,0 +1,62 @@
package com.hlab.yanalyst.domain.channel;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 채널 통계의 일별 스냅샷. Channel.update() 구독자/조회수를 덮어쓰면서 사라지던
* 성장 추이를 보존한다. (채널당 하루 1건, upsert)
*/
@Entity
@Table(name = "channel_snapshots",
uniqueConstraints = @UniqueConstraint(columnNames = {"channel_id", "snapshot_date"}))
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class ChannelSnapshot {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** Channel.id (FK 대신 느슨한 연결). */
@Column(name = "channel_id", nullable = false)
private Long channelId;
@Column(name = "snapshot_date", nullable = false)
private LocalDate snapshotDate;
@Column(name = "subscriber_count")
private Long subscriberCount;
@Column(name = "view_count")
private Long viewCount;
@Column(name = "video_count")
private Long videoCount;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
public ChannelSnapshot(Long channelId, LocalDate snapshotDate, Long subscriberCount, Long viewCount, Long videoCount) {
this.channelId = channelId;
this.snapshotDate = snapshotDate;
this.subscriberCount = subscriberCount;
this.viewCount = viewCount;
this.videoCount = videoCount;
}
public void update(Long subscriberCount, Long viewCount, Long videoCount) {
this.subscriberCount = subscriberCount;
this.viewCount = viewCount;
this.videoCount = videoCount;
}
}

View File

@ -0,0 +1,12 @@
package com.hlab.yanalyst.domain.channel;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
public interface ChannelSnapshotRepository extends JpaRepository<ChannelSnapshot, Long> {
Optional<ChannelSnapshot> findByChannelIdAndSnapshotDate(Long channelId, LocalDate snapshotDate);
List<ChannelSnapshot> findByChannelIdOrderBySnapshotDateAsc(Long channelId);
}

View File

@ -0,0 +1,213 @@
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")
@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;
// --- 큐레이션(분류/관리) 필드 ---
/** 분류 카테고리 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;
@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";
}
/** 출처(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;
}
}

View File

@ -0,0 +1,120 @@
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/channel-videos")
@RequiredArgsConstructor
@Tag(name = "Channel Video Curation API", description = "수집한 채널 영상의 분류/필터/관리")
public class ChannelVideoCurationController {
private final ChannelVideoCurationService curationService;
@GetMapping
@Operation(summary = "큐레이션 조회",
description = "categoryId/status/source(CHANNEL|SEARCH)/shortsOnly/bookmarkedOnly 로 필터, "
+ "sortBy(viewsPerHour|viewsPerSubRatio|viewCount|publishedAt|durationSec) 로 내림차순 정렬.")
public ApiResponse<List<ChannelVideo>> search(
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String source,
@RequestParam(defaultValue = "false") boolean shortsOnly,
@RequestParam(defaultValue = "false") boolean bookmarkedOnly,
@RequestParam(required = false) String sortBy) {
return ApiResponse.ok(curationService.search(categoryId, status, source, shortsOnly, bookmarkedOnly, sortBy));
}
@GetMapping("/outperformers")
@Operation(summary = "떡상 후보 자동 발굴",
description = "구독자 대비 조회수 비율이 높은 Shorts 를 자동 선별. limit(기본20), minRatio(기본2.0).")
public ApiResponse<List<ChannelVideo>> outperformers(
@RequestParam(required = false) Integer limit,
@RequestParam(required = false) java.math.BigDecimal minRatio) {
return ApiResponse.ok(curationService.findOutperformers(limit, minRatio));
}
@PostMapping("/backfill")
@Operation(summary = "기존 수집 영상 지표 백필",
description = "새 컬럼 추가 이전에 수집된 영상의 파생 지표/큐레이션 기본값을 재계산(외부 API 호출 없음, 재실행 안전).")
public ApiResponse<Map<String, Object>> backfill() {
return ApiResponse.ok(curationService.backfillMetrics());
}
@GetMapping("/stats")
@Operation(summary = "수집/파이프라인 통계",
description = "총 수집 수, 상태별/출처별 분포 — 대시보드·칸반 보드용 요약.")
public ApiResponse<Map<String, Object>> stats() {
return ApiResponse.ok(curationService.pipelineStats());
}
@PostMapping("/{id}/category")
@Operation(summary = "카테고리 지정/해제", description = "body: {\"categoryId\": 1} — null 또는 미포함 시 분류 해제")
public ApiResponse<ChannelVideo> assignCategory(@PathVariable Long id, @RequestBody(required = false) Map<String, Object> body) {
Long categoryId = null;
if (body != null && body.get("categoryId") != null) {
categoryId = ((Number) body.get("categoryId")).longValue();
}
return ApiResponse.ok(curationService.assignCategory(id, categoryId));
}
@PostMapping("/{id}/bookmark")
@Operation(summary = "북마크 설정", description = "body: {\"bookmarked\": true}")
public ApiResponse<ChannelVideo> setBookmark(@PathVariable Long id, @RequestBody Map<String, Object> body) {
boolean bookmarked = Boolean.TRUE.equals(body.get("bookmarked"));
return ApiResponse.ok(curationService.setBookmark(id, bookmarked));
}
@PostMapping("/{id}/status")
@Operation(summary = "관심 상태 변경", description = "body: {\"status\": \"TARGET\"} — NEW|REVIEWING|TARGET|EXCLUDED")
public ApiResponse<ChannelVideo> changeStatus(@PathVariable Long id, @RequestBody Map<String, String> body) {
return ApiResponse.ok(curationService.changeStatus(id, body.get("status")));
}
@PostMapping("/{id}/memo")
@Operation(summary = "메모 저장", description = "body: {\"memo\": \"...\"}")
public ApiResponse<ChannelVideo> updateMemo(@PathVariable Long id, @RequestBody Map<String, String> body) {
return ApiResponse.ok(curationService.updateMemo(id, body.get("memo")));
}
@DeleteMapping("/{id}")
@Operation(summary = "수집함에서 영상 제거", description = "연결된 스크립트도 함께 삭제된다.")
public ApiResponse<Void> delete(@PathVariable Long id) {
curationService.delete(id);
return ApiResponse.ok(null);
}
// ===== 재가공(재작성) =====
@GetMapping("/{id}")
@Operation(summary = "수집 영상 단건 조회", description = "재가공 작업공간용 상세 정보.")
public ApiResponse<ChannelVideo> getOne(@PathVariable Long id) {
return ApiResponse.ok(curationService.getOne(id));
}
@GetMapping("/{id}/script")
@Operation(summary = "원본 스크립트 조회", description = "추출된 transcript. 없으면 transcript=null.")
public ApiResponse<Map<String, Object>> getScript(@PathVariable Long id) {
String t = curationService.getTranscript(id);
return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t));
}
@PostMapping("/{id}/extract-script")
@Operation(summary = "원본 스크립트 추출", description = "외부 transcript 서비스로 자막을 추출해 저장한다.")
public ApiResponse<Map<String, Object>> extractScript(@PathVariable Long id) {
String t = curationService.extractTranscript(id);
return ApiResponse.ok(Map.of("hasScript", t != null, "transcript", t == null ? "" : t));
}
@PostMapping("/{id}/rework")
@Operation(summary = "재작성 초안 저장", description = "body: {\"reworkText\": \"...\"} — 저장 시 상태가 TARGET 으로 승격된다.")
public ApiResponse<ChannelVideo> saveRework(@PathVariable Long id, @RequestBody Map<String, String> body) {
return ApiResponse.ok(curationService.saveRework(id, body.get("reworkText")));
}
}

View File

@ -0,0 +1,185 @@
package com.hlab.yanalyst.domain.channel;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Set;
/**
* 수집한 채널 영상의 분류/관리(큐레이션) 로직.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ChannelVideoCurationService {
private final ChannelVideoRepository channelVideoRepository;
private final ChannelVideoScriptRepository channelVideoScriptRepository;
private final ChannelService channelService;
private static final Set<String> ALLOWED_STATUS = Set.of("NEW", "REVIEWING", "TARGET", "DONE", "EXCLUDED");
private static final Set<String> ALLOWED_SORT = Set.of("viewsPerHour", "viewsPerSubRatio", "viewCount", "publishedAt", "durationSec");
private ChannelVideo find(Long id) {
return channelVideoRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + id));
}
@Transactional
public ChannelVideo assignCategory(Long videoId, Long categoryId) {
ChannelVideo video = find(videoId);
video.assignCategory(categoryId);
return video;
}
@Transactional
public ChannelVideo setBookmark(Long videoId, boolean bookmarked) {
ChannelVideo video = find(videoId);
video.setBookmarked(bookmarked);
return video;
}
@Transactional
public ChannelVideo changeStatus(Long videoId, String status) {
if (!ALLOWED_STATUS.contains(status)) {
throw new IllegalArgumentException("허용되지 않은 상태값입니다: " + status + " (가능: " + ALLOWED_STATUS + ")");
}
ChannelVideo video = find(videoId);
video.changeInterestStatus(status);
return video;
}
@Transactional
public ChannelVideo updateMemo(Long videoId, String memo) {
ChannelVideo video = find(videoId);
video.setMemo(memo);
return video;
}
// ===== 재가공(재작성) 연결 =====
public ChannelVideo getOne(Long videoId) {
return find(videoId);
}
/** 원본 스크립트(transcript) 조회. 없으면 null. */
public String getTranscript(Long videoId) {
ChannelVideo v = find(videoId);
return channelVideoScriptRepository.findByVideoId(v.getVideoId())
.map(ChannelVideoScript::getTranscript)
.orElse(null);
}
/** 원본 스크립트 추출(외부 transcript 서비스 호출). 추출 후 transcript 반환. */
@Transactional
public String extractTranscript(Long videoId) {
ChannelVideo v = find(videoId);
channelService.extractScript(v.getId()); // channel_video_scripts 저장 + hasScript=true
return channelVideoScriptRepository.findByVideoId(v.getVideoId())
.map(ChannelVideoScript::getTranscript)
.orElse(null);
}
/** 재작성 초안 저장 + 상태를 TARGET 으로 승격(아직 NEW/REVIEWING 이면). */
@Transactional
public ChannelVideo saveRework(Long videoId, String text) {
ChannelVideo v = find(videoId);
v.setReworkText(text);
if (!"EXCLUDED".equals(v.getInterestStatus())) {
v.changeInterestStatus("TARGET");
}
return v;
}
/** 수집함에서 영상 제거(연결된 스크립트도 함께 삭제). */
@Transactional
public void delete(Long videoId) {
ChannelVideo video = find(videoId);
channelVideoScriptRepository.findByVideoId(video.getVideoId())
.ifPresent(channelVideoScriptRepository::delete);
channelVideoRepository.delete(video);
}
/**
* 큐레이션 필터 + 정렬 조회.
* @param source 수집 경로 필터: CHANNEL | SEARCH (null 이면 전체)
* @param sortBy viewsPerHour | viewsPerSubRatio | viewCount | publishedAt | durationSec (기본 viewsPerHour, 내림차순)
*/
public List<ChannelVideo> search(Long categoryId, String status, String source,
boolean shortsOnly, boolean bookmarkedOnly, String sortBy) {
String sortField = StringUtils.hasText(sortBy) && ALLOWED_SORT.contains(sortBy) ? sortBy : "viewsPerHour";
String sourceFilter = StringUtils.hasText(source) ? source : null;
Sort sort = Sort.by(Sort.Direction.DESC, sortField);
return channelVideoRepository.search(categoryId, status, sourceFilter, shortsOnly, bookmarkedOnly, sort);
}
/**
* 떡상 후보 자동 발굴 "구독자 적은데 조회수 터진" Shorts 비율 높은 순으로.
* @param limit 최대 개수 (기본 20)
* @param minRatio 최소 (조회수/구독자) 비율 (기본 2.0 = 구독자의 2배 이상 조회)
*/
public List<ChannelVideo> findOutperformers(Integer limit, java.math.BigDecimal minRatio) {
int size = (limit == null || limit <= 0) ? 20 : Math.min(limit, 200);
java.math.BigDecimal threshold = (minRatio == null) ? java.math.BigDecimal.valueOf(2) : minRatio;
return channelVideoRepository.findOutperformers(threshold,
org.springframework.data.domain.PageRequest.of(0, size));
}
/**
* 기존 수집 영상의 파생 지표/큐레이션 기본값 백필.
* 컬럼 추가 이전에 수집된 행들은 값이 null 이라 통계/필터/떡상 발굴에 잡히지 않으므로,
* 이미 보관 중인 duration/viewCount/publishedAt + 연결 채널 정보로 재계산한다(외부 API 호출 없음).
* 재실행해도 안전(idempotent).
*/
@Transactional
public java.util.Map<String, Object> backfillMetrics() {
java.util.List<ChannelVideo> all = channelVideoRepository.findAll();
int updated = 0;
for (ChannelVideo v : all) {
Integer durationSec = VideoMetrics.parseDurationSec(v.getDuration());
Boolean isShorts = VideoMetrics.isShorts(durationSec);
java.math.BigDecimal viewsPerHour = VideoMetrics.viewsPerHour(v.getViewCount(), v.getPublishedAt());
v.applyMetrics(durationSec, isShorts, viewsPerHour);
// 채널 연결이 있으면 채널 정보/구독자 대비 비율 채움 (SEARCH 수집물은 이미 채워져 있어 건너뜀)
if ("SEARCH".equals(v.getSource())) {
if (v.getSubscriberCount() != null) {
v.applyChannelInfoKeepSource(v.getYtChannelId(), v.getChannelTitle(), v.getSubscriberCount(),
VideoMetrics.viewsPerSubRatio(v.getViewCount(), v.getSubscriberCount()));
}
} else if (v.getChannel() != null) {
Long subs = v.getChannel().getSubscriberCount();
v.applyChannelInfo(v.getChannel().getChannelId(), v.getChannel().getTitle(), subs,
VideoMetrics.viewsPerSubRatio(v.getViewCount(), subs));
}
v.applyCurationDefaults();
channelVideoRepository.save(v);
updated++;
}
return java.util.Map.of("updated", updated);
}
/** 대시보드/칸반용 파이프라인 통계: 총계 + 상태별/출처별 분포. */
public java.util.Map<String, Object> pipelineStats() {
java.util.Map<String, Object> stats = new java.util.LinkedHashMap<>();
stats.put("total", channelVideoRepository.count());
java.util.Map<String, Long> byStatus = new java.util.LinkedHashMap<>();
for (String s : ALLOWED_STATUS) {
byStatus.put(s, channelVideoRepository.countByInterestStatus(s));
}
stats.put("byStatus", byStatus);
java.util.Map<String, Long> bySource = new java.util.LinkedHashMap<>();
bySource.put("CHANNEL", channelVideoRepository.countBySource("CHANNEL"));
bySource.put("SEARCH", channelVideoRepository.countBySource("SEARCH"));
stats.put("bySource", bySource);
return stats;
}
}

View File

@ -0,0 +1,44 @@
package com.hlab.yanalyst.domain.channel;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long> {
Optional<ChannelVideo> findByVideoId(String videoId);
boolean existsByVideoId(String videoId);
java.util.List<ChannelVideo> findByChannelId(Long channelId);
java.util.List<ChannelVideo> findByChannelIdInOrderByPublishedAtDesc(java.util.List<Long> channelIds);
// --- 큐레이션/분석 조회 ---
java.util.List<ChannelVideo> findByCategoryId(Long categoryId);
long countByCategoryId(Long categoryId);
long countByInterestStatus(String interestStatus);
long countBySource(String source);
/** 떡상 후보: 구독자 대비 조회수 비율이 높은 Shorts (제외 처리된 것은 빼고). */
@Query("select v from ChannelVideo v where v.isShorts = true "
+ "and v.viewsPerSubRatio >= :minRatio and v.interestStatus <> 'EXCLUDED' "
+ "order by v.viewsPerSubRatio desc")
java.util.List<ChannelVideo> findOutperformers(@Param("minRatio") java.math.BigDecimal minRatio,
org.springframework.data.domain.Pageable pageable);
/**
* 큐레이션 필터링 + 조회수/업로드일/시간당조회수 정렬은 호출부의 Sort 처리.
* null 조건은 무시한다.
*/
@Query("select v from ChannelVideo v where "
+ "(:categoryId is null or v.categoryId = :categoryId) and "
+ "(:status is null or v.interestStatus = :status) and "
+ "(:source is null or v.source = :source) and "
+ "(:shortsOnly = false or v.isShorts = true) and "
+ "(:bookmarkedOnly = false or v.bookmarked = true)")
java.util.List<ChannelVideo> search(@Param("categoryId") Long categoryId,
@Param("status") String status,
@Param("source") String source,
@Param("shortsOnly") boolean shortsOnly,
@Param("bookmarkedOnly") boolean bookmarkedOnly,
org.springframework.data.domain.Sort sort);
}

View File

@ -0,0 +1,38 @@
package com.hlab.yanalyst.domain.channel;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Entity
@Getter
@Setter
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
@Table(name = "channel_video_scripts")
public class ChannelVideoScript {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "channel_video_id")
private Long channelVideoId;
@Column(name = "video_id", nullable = false)
private String videoId;
private String language;
@Column(columnDefinition = "TEXT")
private String transcript;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,10 @@
package com.hlab.yanalyst.domain.channel;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ChannelVideoScriptRepository extends JpaRepository<ChannelVideoScript, Long> {
Optional<ChannelVideoScript> findByVideoId(String videoId);
boolean existsByVideoId(String videoId);
}

View File

@ -0,0 +1,65 @@
package com.hlab.yanalyst.domain.channel;
import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 조회수 검색 결과(YoutubeSearchResultDto) 수집함(ChannelVideo, source=SEARCH)으로 영속화한다.
* 이미 수집된 videoId 건너뛴다(중복 방지).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SearchCollectionService {
private final ChannelVideoRepository channelVideoRepository;
@Transactional
public CollectResult collectFromSearch(List<YoutubeSearchResultDto> items) {
if (items == null || items.isEmpty()) {
return new CollectResult(0, 0, List.of());
}
int saved = 0;
int skipped = 0;
List<String> savedIds = new ArrayList<>();
for (YoutubeSearchResultDto dto : items) {
if (dto.getVideoId() == null || dto.getVideoId().isBlank()) {
skipped++;
continue;
}
if (channelVideoRepository.existsByVideoId(dto.getVideoId())) {
skipped++; // 이미 수집된 영상
continue;
}
BigDecimal viewsPerHour = VideoMetrics.viewsPerHour(dto.getViewCount(), dto.getPublishedAt());
BigDecimal viewsPerSubRatio = VideoMetrics.viewsPerSubRatio(dto.getViewCount(), dto.getSubscriberCount());
String hashtags = (dto.getHashtags() == null || dto.getHashtags().isEmpty())
? null : String.join(",", dto.getHashtags());
ChannelVideo video = ChannelVideo.fromSearch(
dto.getVideoId(), dto.getTitle(), dto.getThumbnailUrl(), dto.getPublishedAt(),
dto.getViewCount(), dto.getChannelId(), dto.getChannelTitle(), dto.getSubscriberCount(),
dto.getDurationSec(), viewsPerHour, viewsPerSubRatio, hashtags);
channelVideoRepository.save(video);
saved++;
savedIds.add(dto.getVideoId());
}
log.info("Search collection done. saved={}, skipped(duplicate/invalid)={}", saved, skipped);
return new CollectResult(saved, skipped, savedIds);
}
/** 수집 결과 요약. */
public record CollectResult(int saved, int skipped, List<String> savedVideoIds) {}
}

View File

@ -0,0 +1,44 @@
package com.hlab.yanalyst.domain.channel;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* 수집 영상의 파생 분석 지표 계산 유틸. 채널 수집/검색 수집 양쪽에서 공용으로 사용.
*/
public final class VideoMetrics {
private VideoMetrics() {}
/** ISO 8601 duration(예: PT1M5S) → 초. 실패 시 null. */
public static Integer parseDurationSec(String isoDuration) {
if (isoDuration == null || isoDuration.isBlank()) return null;
try {
return (int) Duration.parse(isoDuration).getSeconds();
} catch (Exception e) {
return null;
}
}
public static boolean isShorts(Integer durationSec) {
return durationSec != null && durationSec <= 65;
}
/** 시간당 조회수 = 조회수 / 업로드 후 경과 시간(시간). 경과 1시간 미만은 1시간으로 간주. */
public static BigDecimal viewsPerHour(Long viewCount, LocalDateTime publishedAt) {
if (viewCount == null || publishedAt == null) return BigDecimal.ZERO;
long hours = Duration.between(publishedAt, LocalDateTime.now()).toHours();
if (hours < 1) hours = 1;
return BigDecimal.valueOf(viewCount)
.divide(BigDecimal.valueOf(hours), 2, RoundingMode.HALF_UP);
}
/** 구독자 대비 조회수 비율 = 조회수 / 구독자. "구독자 적은데 터진 영상" 발굴용. 구독자 0/누락 시 0. */
public static BigDecimal viewsPerSubRatio(Long viewCount, Long subscriberCount) {
if (viewCount == null || subscriberCount == null || subscriberCount <= 0) return BigDecimal.ZERO;
return BigDecimal.valueOf(viewCount)
.divide(BigDecimal.valueOf(subscriberCount), 2, RoundingMode.HALF_UP);
}
}

View File

@ -0,0 +1,65 @@
package com.hlab.yanalyst.domain.opal;
import com.hlab.yanalyst.domain.video.YtVideo;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import java.time.LocalDateTime;
@Entity
@Table(name = "opal_draft")
@Getter
@Setter
@NoArgsConstructor
public class OpalDraft {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "draft_id")
private Long draftId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "video_id", nullable = false)
private YtVideo video;
@Type(JsonBinaryType.class)
@Column(name = "request_payload_json", columnDefinition = "jsonb")
private String requestPayloadJson;
@Column(name = "response_text", columnDefinition = "TEXT")
private String responseText;
@Column(name = "old_script_summary", columnDefinition = "TEXT")
private String oldScriptSummary;
@Column(name = "new_script_summary", columnDefinition = "TEXT")
private String newScriptSummary;
@Column(name = "user_feedback", columnDefinition = "TEXT")
private String userFeedback;
@Column(name = "version_no", nullable = false)
private Integer versionNo;
@Column(name = "is_accepted", nullable = false)
private Boolean isAccepted = false;
@Column(name = "accepted_at")
private LocalDateTime acceptedAt;
@Column(name = "status", nullable = false, length = 20)
private String status = "SUCCESS";
@Column(name = "error_msg", columnDefinition = "TEXT")
private String errorMsg;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,9 @@
package com.hlab.yanalyst.domain.opal;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface OpalDraftRepository extends JpaRepository<OpalDraft, Long> {
List<OpalDraft> findByVideo_VideoIdOrderByVersionNoDesc(Long videoId);
Integer countByVideo_VideoId(Long videoId);
}

View File

@ -0,0 +1,45 @@
package com.hlab.yanalyst.domain.opal;
import com.hlab.yanalyst.domain.video.YtVideo;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "opal_final")
@Getter
@Setter
@NoArgsConstructor
public class OpalFinal {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "final_id")
private Long finalId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "video_id", nullable = false)
private YtVideo video;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "draft_id", nullable = false)
private OpalDraft draft;
@Column(name = "final_script_text", nullable = false, columnDefinition = "TEXT")
private String finalScriptText;
@Column(name = "is_active", nullable = false)
private Boolean isActive = false;
@CreationTimestamp
@Column(name = "finalized_at", nullable = false)
private LocalDateTime finalizedAt;
@Column(name = "status", nullable = false, length = 20)
private String status = "FINALIZED";
}

View File

@ -0,0 +1,54 @@
package com.hlab.yanalyst.domain.opal;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import java.time.LocalDateTime;
@Entity
@Table(name = "opal_final_asset")
@Getter
@Setter
@NoArgsConstructor
public class OpalFinalAsset {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "asset_id")
private Long assetId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "final_id", nullable = false)
private OpalFinal opalFinal;
@Type(JsonBinaryType.class)
@Column(name = "asset_json", nullable = false, columnDefinition = "jsonb")
private String assetJson;
@Column(name = "title", columnDefinition = "TEXT")
private String title;
@Column(name = "summary", columnDefinition = "TEXT")
private String summary;
@Type(JsonBinaryType.class)
@Column(name = "timeline", columnDefinition = "jsonb")
private String timeline;
@Column(name = "video_prompt", columnDefinition = "TEXT")
private String videoPrompt;
@Type(JsonBinaryType.class)
@Column(name = "image_urls", columnDefinition = "jsonb")
private String imageUrls;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,8 @@
package com.hlab.yanalyst.domain.opal;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface OpalFinalAssetRepository extends JpaRepository<OpalFinalAsset, Long> {
Optional<OpalFinalAsset> findByOpalFinal_FinalId(Long finalId);
}

View File

@ -0,0 +1,10 @@
package com.hlab.yanalyst.domain.opal;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.List;
public interface OpalFinalRepository extends JpaRepository<OpalFinal, Long> {
Optional<OpalFinal> findByVideo_VideoIdAndIsActiveTrue(Long videoId);
List<OpalFinal> findByVideo_VideoId(Long videoId);
}

View File

@ -0,0 +1,15 @@
package com.hlab.yanalyst.domain.opal.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OpalDraftResponseDto {
private String oldScriptSummary;
private String newScriptSummary;
}

View File

@ -0,0 +1,74 @@
package com.hlab.yanalyst.domain.production;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/production")
@RequiredArgsConstructor
@Tag(name = "Production API", description = "Production Management API")
public class ProductionController {
private final ProductionService productionService;
@PostMapping("/fetch-rankings")
@Operation(summary = "Fetch Rankings", description = "Fetch ranking data from external n8n webhook.")
public ApiResponse<String> fetchRankings() {
return ApiResponse.ok(productionService.fetchRankings());
}
@PostMapping("/extract-script")
@Operation(summary = "Extract Script", description = "Extract transcript from a video URL.")
public ApiResponse<Void> extractScript(@RequestBody Map<String, Long> payload) {
Long productionVideoId = payload.get("productionVideoId");
productionService.extractScript(productionVideoId);
return ApiResponse.ok(null);
}
@org.springframework.web.bind.annotation.GetMapping("/script/{scriptId}")
@Operation(summary = "Get Script", description = "Get transcript details by script ID.")
public ApiResponse<com.hlab.yanalyst.domain.production.ProductionScript> getScript(@org.springframework.web.bind.annotation.PathVariable Long scriptId) {
return ApiResponse.ok(productionService.getScript(scriptId));
}
@PostMapping("/fetch-summary")
@Operation(summary = "Fetch Summary", description = "Fetch and save summary from Google Docs for a video.")
public ApiResponse<ProductionScript> fetchSummary(@RequestBody Map<String, Long> payload) {
Long videoId = payload.get("videoId");
ProductionScript script = productionService.fetchAndSaveSummary(videoId);
return ApiResponse.ok(script);
}
@PostMapping("/fetch-final-script")
@Operation(summary = "Fetch Final Script", description = "Fetch and save final script from Google Docs for a video.")
public ApiResponse<ProductionScript> fetchFinalScript(@RequestBody Map<String, Long> payload) {
Long videoId = payload.get("videoId");
ProductionScript script = productionService.fetchAndSaveFinalScript(videoId);
return ApiResponse.ok(script);
}
@PostMapping("/update-final-script")
@Operation(summary = "Update Final Script", description = "Update the content of the final script.")
public ApiResponse<ProductionScript> updateFinalScript(@RequestBody Map<String, Object> payload) {
Long scriptId = ((Number) payload.get("scriptId")).longValue();
String content = (String) payload.get("content");
ProductionScript script = productionService.updateFinalScript(scriptId, content);
return ApiResponse.ok(script);
}
@PostMapping("/fetch-opening")
@Operation(summary = "Fetch Opening Script", description = "Fetch and save opening script from Google Docs for a video.")
public ApiResponse<ProductionScript> fetchOpening(@RequestBody Map<String, Long> payload) {
Long videoId = payload.get("videoId");
ProductionScript script = productionService.fetchAndSaveOpening(videoId);
return ApiResponse.ok(script);
}
}

View File

@ -0,0 +1,42 @@
package com.hlab.yanalyst.domain.production;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
@Table(name = "production_crawl_history")
public class ProductionCrawlHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate
private LocalDateTime crawledAt;
private Integer topN;
private Integer videoCount;
@OneToMany(mappedBy = "history", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ProductionVideo> videos = new ArrayList<>();
public ProductionCrawlHistory(Integer topN, Integer videoCount) {
this.topN = topN;
this.videoCount = videoCount;
}
public void addVideo(ProductionVideo video) {
this.videos.add(video);
video.setHistory(this);
}
}

View File

@ -0,0 +1,6 @@
package com.hlab.yanalyst.domain.production;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductionCrawlHistoryRepository extends JpaRepository<ProductionCrawlHistory, Long> {
}

View File

@ -0,0 +1,50 @@
package com.hlab.yanalyst.domain.production;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Entity
@Getter
@Setter
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
@Table(name = "production_script")
public class ProductionScript {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "production_video_id")
private Long productionVideoId;
@Column(name = "youtube_video_id")
private String youtubeVideoId;
private String language;
@Column(columnDefinition = "TEXT")
private String transcript;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(columnDefinition = "TEXT")
private String oldScriptSummary;
@Column(columnDefinition = "TEXT")
private String newScriptSummary;
@Column(columnDefinition = "TEXT")
private String finalScript;
@Column(columnDefinition = "TEXT")
private String openingScript;
}

View File

@ -0,0 +1,7 @@
package com.hlab.yanalyst.domain.production;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductionScriptRepository extends JpaRepository<ProductionScript, Long> {
java.util.List<ProductionScript> findByProductionVideoIdIn(java.util.List<Long> productionVideoIds);
}

View File

@ -0,0 +1,413 @@
package com.hlab.yanalyst.domain.production;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hlab.yanalyst.domain.production.dto.RankingItemDto;
import com.hlab.yanalyst.domain.production.dto.RankingResponseDto;
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp;
import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.util.store.FileDataStoreFactory;
import com.google.api.services.docs.v1.Docs;
import com.google.api.services.docs.v1.DocsScopes;
import com.google.api.services.docs.v1.model.*;
import org.springframework.web.client.RestTemplate;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProductionService {
private final RestTemplate restTemplate;
private final ProductionCrawlHistoryRepository historyRepository;
private final ObjectMapper objectMapper; // Jackson ObjectMapper
@Transactional
public String fetchRankings() {
// String url = "https://h-n8n.tolag.shop/webhook/2ac04910-8243-4e82-9187-df4db8087a5c";
String url = "https://h-n8n.tolag.shop/webhook/2ac04910-8243";
log.info("Fetching rankings from external API: {}", url);
try {
ResponseEntity<String> response = restTemplate.postForEntity(url, null, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
String body = response.getBody();
log.info("Successfully fetched rankings data: {}", body);
// Save to DB
saveRankingsToDb(body);
return body;
} else {
log.error("Failed to fetch rankings. Status: {}", response.getStatusCode());
throw new RuntimeException("Failed to fetch rankings from external API");
}
} catch (Exception e) {
log.error("Error occurred while fetching rankings", e);
throw new RuntimeException("Error fetching rankings", e);
}
}
private void saveRankingsToDb(String jsonBody) {
try {
RankingResponseDto dto = objectMapper.readValue(jsonBody, RankingResponseDto.class);
ProductionCrawlHistory history = new ProductionCrawlHistory(dto.getTopN(), dto.getItems().size());
// auditing usually handles createdDate, but if not set up, we might need manual set.
// @CreatedDate requires @EnableJpaAuditing. Assuming it's there or will work.
// If strictly needed, we can set manually: history.setCrawledAt(LocalDateTime.now());
if (dto.getItems() != null) {
for (RankingItemDto itemDto : dto.getItems()) {
ProductionVideo video = new ProductionVideo();
video.setRank(itemDto.getRank());
video.setTitle(itemDto.getTitle());
video.setVideoUrl(itemDto.getVideoUrl());
video.setThumbnailUrl(itemDto.getThumbnailUrl());
video.setChannelTitle(itemDto.getChannelTitle());
video.setViewCount(itemDto.getViewCount());
video.setSubscriberCount(itemDto.getSubscriberCount());
video.setViewsPerHour(itemDto.getViewsPerHour());
video.setPublishedAt(itemDto.getPublishedAt());
history.addVideo(video);
}
}
historyRepository.save(history);
log.info("Saved crawl history with ID: {}", history.getId());
} catch (JsonProcessingException e) {
log.error("Failed to parse ranking JSON", e);
// We don't throw exception here to allow the raw JSON to at least return to frontend if needed,
// or we could throw. Let's log and rethrow safely or just proceed.
// But if we fail to save, user might want to know.
throw new RuntimeException("Failed to parse and save rankings", e);
}
}
public List<ProductionCrawlHistory> getAllHistory() {
return historyRepository.findAll(Sort.by(Sort.Direction.DESC, "id")); // Sort by ID desc - most recent first
}
public ProductionCrawlHistory getHistory(Long id) {
ProductionCrawlHistory history = historyRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("History not found with id: " + id));
// Populate transient fields
List<Long> videoIds = history.getVideos().stream()
.map(ProductionVideo::getId)
.toList();
if (!videoIds.isEmpty()) {
Map<Long, ProductionScript> scriptMap = productionScriptRepository.findByProductionVideoIdIn(videoIds).stream()
.collect(java.util.stream.Collectors.toMap(ProductionScript::getProductionVideoId, script -> script));
for (ProductionVideo video : history.getVideos()) {
if (scriptMap.containsKey(video.getId())) {
ProductionScript script = scriptMap.get(video.getId());
video.setHasScript(true);
video.setScriptId(script.getId());
// Check if summaries exist
if (script.getOldScriptSummary() != null && !script.getOldScriptSummary().isEmpty()) {
video.setHasSummary(true);
}
// Check if final script exists
if (script.getFinalScript() != null && !script.getFinalScript().isEmpty()) {
video.setHasFinalScript(true);
}
// Check if opening script exists
if (script.getOpeningScript() != null && !script.getOpeningScript().isEmpty()) {
video.setHasOpening(true);
}
}
}
}
return history;
}
private final ProductionVideoRepository videoRepository;
private final ProductionScriptRepository productionScriptRepository;
@Transactional
public void extractScript(Long productionVideoId) {
ProductionVideo video = videoRepository.findById(productionVideoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + productionVideoId));
String apiUrl = "http://h-python.tolag.shop/transcript";
Map<String, String> requestBody = Collections.singletonMap("url", video.getVideoUrl());
log.info("Requesting transcript for URL: {}", video.getVideoUrl());
try {
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, requestBody, String.class);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
ScriptResponseDto scriptDto =
objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
ProductionScript script = new ProductionScript();
script.setProductionVideoId(productionVideoId);
script.setYoutubeVideoId(scriptDto.getVideoId());
script.setLanguage(scriptDto.getLanguage());
script.setTranscript(scriptDto.getTranscript());
productionScriptRepository.save(script);
log.info("Saved script for video id: {}", productionVideoId);
} else {
log.error("Failed to fetch script. Status: {}", response.getStatusCode());
throw new RuntimeException("External API failed with status: " + response.getStatusCode());
}
} catch (Exception e) {
log.error("Error extracting script", e);
throw new RuntimeException("Error extracting script", e);
}
}
public ProductionScript getScript(Long scriptId) {
return productionScriptRepository.findById(scriptId)
.orElseThrow(() -> new IllegalArgumentException("Script not found with id: " + scriptId));
}
@Transactional
public ProductionScript fetchAndSaveSummary(Long videoId) {
// Check if video exists
ProductionVideo video = videoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + videoId));
// Find the script associated with this video.
ProductionScript script = productionScriptRepository.findByProductionVideoIdIn(Collections.singletonList(videoId))
.stream().findFirst()
.orElseThrow(() -> new IllegalArgumentException("Script record not found for video: " + videoId));
// Doc ID for Opal Drafts / Summary
String docId = "11eFwYXm1Ld2vZUrOHvJZEmN69KDXvQygEysdXWr8-W4";
log.info("Fetching summary using Google Docs API for video ID {} from Doc ID: {}", videoId, docId);
try {
// 1. Fetch content using API
String text = readGoogleDoc(docId);
// Parse
String oldSummaryMarker = "old_script_summary";
String newSummaryMarker = "new_script_summary";
int oldStart = text.indexOf(oldSummaryMarker);
int newStart = text.indexOf(newSummaryMarker);
if (oldStart != -1 && newStart != -1) {
String oldSummary = text.substring(oldStart + oldSummaryMarker.length(), newStart).trim();
String newSummary = text.substring(newStart + newSummaryMarker.length()).trim();
System.out.println("Saving summaries for video " + videoId);
script.setOldScriptSummary(oldSummary);
script.setNewScriptSummary(newSummary);
ProductionScript savedScript = productionScriptRepository.save(script);
// 2. Clear the Google Doc content
clearGoogleDoc(docId);
log.info("Cleared content of Google Doc: {}", docId);
return savedScript;
} else {
log.warn("Summary markers not found in the text file.");
// If markers are missing, do we clear? user probably wants to fix it manually.
// Let's NOT clear if parsing fails, so they can debug the doc.
throw new RuntimeException("Summary markers not found in Google Doc");
}
} catch (Exception e) {
log.error("Error fetching/clearing summary", e);
throw new RuntimeException("Error fetching summary", e);
}
}
@Transactional
public ProductionScript fetchAndSaveFinalScript(Long videoId) {
// Check if video exists
ProductionVideo video = videoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + videoId));
// Find the script associated with this video.
ProductionScript script = productionScriptRepository.findByProductionVideoIdIn(Collections.singletonList(videoId))
.stream().findFirst()
.orElseThrow(() -> new IllegalArgumentException("Script record not found for video: " + videoId));
// Doc ID for Final Script
// String docId = "1tThnN2-OdYS-RuAWUWFsW9W238TPqZssaww5_wULOg4";
String docId = "1jiSEwFuWeggIFln08j15pXw-8iUsFaDVjVKl3RbVdsw";
log.info("Fetching final script using Google Docs API for video ID {} from Doc ID: {}", videoId, docId);
try {
// 1. Fetch content using API
String text = readGoogleDoc(docId);
System.out.println("Saving final script for video " + videoId);
// 2. Save to DB
script.setFinalScript(text);
ProductionScript savedScript = productionScriptRepository.save(script);
// 3. Clear the Google Doc content
clearGoogleDoc(docId);
log.info("Cleared content of Google Doc: {}", docId);
return savedScript;
} catch (Exception e) {
log.error("Error fetching/clearing final script", e);
throw new RuntimeException("Error fetching final script", e);
}
}
@Transactional
public ProductionScript fetchAndSaveOpening(Long videoId) {
// Check if video exists
ProductionVideo video = videoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + videoId));
// Find the script associated with this video.
ProductionScript script = productionScriptRepository.findByProductionVideoIdIn(Collections.singletonList(videoId))
.stream().findFirst()
.orElseThrow(() -> new IllegalArgumentException("Script record not found for video: " + videoId));
// Doc ID for Opening
String docId = "1djTi1eo73zSyZIveChgS94S754hUS9JQX0NaaaSjHBo";
log.info("Fetching opening script using Google Docs API for video ID {} from Doc ID: {}", videoId, docId);
try {
// 1. Fetch content using API
String text = readGoogleDoc(docId);
System.out.println("Saving opening script for video " + videoId);
// 2. Save to DB
script.setOpeningScript(text);
ProductionScript savedScript = productionScriptRepository.save(script);
// 3. Clear the Google Doc content
clearGoogleDoc(docId);
log.info("Cleared content of Google Doc: {}", docId);
return savedScript;
} catch (Exception e) {
log.error("Error fetching/clearing opening script", e);
throw new RuntimeException("Error fetching opening script", e);
}
}
@Transactional
public ProductionScript updateFinalScript(Long scriptId, String content) {
ProductionScript script = productionScriptRepository.findById(scriptId)
.orElseThrow(() -> new IllegalArgumentException("Script not found with id: " + scriptId));
script.setFinalScript(content);
return productionScriptRepository.save(script);
}
// --- Google Docs Helper Methods ---
private static final String CREDENTIALS_FILE_PATH = "/credentials.json";
private static final java.util.List<String> SCOPES = Collections.singletonList(DocsScopes.DOCUMENTS);
private static final String TOKENS_DIRECTORY_PATH = "tokens";
private Docs getDocsService() throws Exception {
final NetHttpTransport HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport();
GsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
InputStream in = ProductionService.class.getResourceAsStream(CREDENTIALS_FILE_PATH);
if (in == null) {
throw new FileNotFoundException("Resource not found: " + CREDENTIALS_FILE_PATH);
}
GoogleClientSecrets clientSecrets = GoogleClientSecrets.load(JSON_FACTORY, new InputStreamReader(in));
// Build flow and trigger user authorization request.
GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT, JSON_FACTORY, clientSecrets, SCOPES)
.setDataStoreFactory(new FileDataStoreFactory(new java.io.File(TOKENS_DIRECTORY_PATH)))
.setAccessType("offline")
.build();
LocalServerReceiver receiver = new LocalServerReceiver.Builder().setPort(8888).build();
// This authorize call will open browser if token is missing
Credential credential = new AuthorizationCodeInstalledApp(flow, receiver).authorize("user");
return new Docs.Builder(HTTP_TRANSPORT, JSON_FACTORY, credential)
.setApplicationName("HLAB-Backend")
.build();
}
private String readGoogleDoc(String docId) throws Exception {
Docs service = getDocsService();
Document doc = service.documents().get(docId).execute();
StringBuilder sb = new StringBuilder();
doc.getBody().getContent().forEach(c -> {
if (c.getParagraph() != null) {
c.getParagraph().getElements().forEach(e -> {
if (e.getTextRun() != null) {
sb.append(e.getTextRun().getContent());
}
});
sb.append("\n"); // Add line break for paragraphs
}
});
return sb.toString();
}
private void clearGoogleDoc(String docId) throws Exception {
Docs service = getDocsService();
Document doc = service.documents().get(docId).execute();
// Calculate the end index. Content usually ends with a newline.
int lastContentIndex = doc.getBody().getContent().size() - 1;
int docEnd = doc.getBody().getContent().get(lastContentIndex).getEndIndex();
log.info("Attempting to clear doc {}. Last content index: {}, Doc End Index: {}", docId, lastContentIndex, docEnd);
// Safety check: if doc is already practically empty
// Minimum doc with just a newline usually has end index around 2 or so.
if (docEnd > 2) {
int deleteEndIndex = docEnd - 1;
log.info("Deleting range: 1 to {}", deleteEndIndex);
Request request = new Request()
.setDeleteContentRange(new DeleteContentRangeRequest()
.setRange(new Range().setStartIndex(1).setEndIndex(deleteEndIndex)));
BatchUpdateDocumentRequest body = new BatchUpdateDocumentRequest().setRequests(Collections.singletonList(request));
BatchUpdateDocumentResponse response = service.documents().batchUpdate(docId, body).execute();
log.info("Clear doc response: {}", response);
} else {
log.info("Doc appears empty or too small to clear (endIndex <= 2). Skipping delete.");
}
}
}

View File

@ -0,0 +1,60 @@
package com.hlab.yanalyst.domain.production;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "production_video")
public class ProductionVideo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "history_id")
private ProductionCrawlHistory history;
@Column(name = "video_rank") // 'rank' is a reserved keyword in some DBs
private Integer rank;
@Column(length = 500)
private String title;
@Column(length = 2083)
private String videoUrl;
@Column(length = 2083)
private String thumbnailUrl;
private String channelTitle;
// Using Long/Double wrappers to allow nulls if data is missing
private Long viewCount;
private Long subscriberCount;
private Double viewsPerHour;
private LocalDateTime publishedAt;
@Transient
private boolean hasScript;
@Transient
private Long scriptId;
@Transient
private boolean hasSummary;
@Transient
private boolean hasFinalScript;
@Transient
private boolean hasOpening;
}

View File

@ -0,0 +1,6 @@
package com.hlab.yanalyst.domain.production;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductionVideoRepository extends JpaRepository<ProductionVideo, Long> {
}

View File

@ -0,0 +1,30 @@
package com.hlab.yanalyst.domain.production.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class RankingItemDto {
private Integer rank;
private String youtubeVideoId; // might be null based on sample, but useful if present
private String videoUrl;
private String title;
private String channelTitle;
// The format in the JSON provided is standard ISO-8601 (e.g. 2025-12-29T13:44:51Z)
// Jackson handles this automatically with 'LocalDateTime' usually if JavaTimeModule is registered,
// or we can strictly define pattern. The Z indicates UTC.
// Let's use generic parsing or ZonedDateTime if errors occur, but LocalDateTime is usually safe if simple.
// Actually, "2025-12-29T13:44:51Z" is Instant-compatible.
private LocalDateTime publishedAt;
private Long viewCount;
private Long likeCount;
private Long commentCount;
private Long subscriberCount;
private Double viewsPerHour;
private Double viewsPerSub;
private String thumbnailUrl;
}

View File

@ -0,0 +1,10 @@
package com.hlab.yanalyst.domain.production.dto;
import lombok.Data;
import java.util.List;
@Data
public class RankingResponseDto {
private Integer topN;
private List<RankingItemDto> items;
}

View File

@ -0,0 +1,13 @@
package com.hlab.yanalyst.domain.production.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class ScriptResponseDto {
@JsonProperty("video_id")
private String videoId;
private String language;
private String transcript;
}

View File

@ -0,0 +1,53 @@
package com.hlab.yanalyst.domain.publish;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/publish")
@RequiredArgsConstructor
@Tag(name = "Publish API", description = "발행(배포) 패키지 준비/추적")
public class PublishController {
private final PublishService publishService;
@GetMapping("/by-video/{channelVideoId}")
@Operation(summary = "영상별 발행 패키지 조회", description = "없으면 data=null.")
public ApiResponse<PublishPackage> getByVideo(@PathVariable Long channelVideoId) {
return ApiResponse.ok(publishService.getByVideo(channelVideoId));
}
@PostMapping("/by-video/{channelVideoId}")
@Operation(summary = "발행 패키지 저장(upsert)",
description = "body: {title, description, hashtags, platform, scheduledAt(ISO), status(DRAFT|READY|PUBLISHED)}")
public ApiResponse<PublishPackage> upsert(@PathVariable Long channelVideoId, @RequestBody Map<String, String> body) {
LocalDateTime scheduledAt = null;
String s = body.get("scheduledAt");
if (s != null && !s.isBlank()) {
scheduledAt = LocalDateTime.parse(s.length() == 16 ? s + ":00" : s);
}
PublishPackage p = publishService.upsert(channelVideoId,
body.get("title"), body.get("description"), body.get("hashtags"),
body.get("platform"), scheduledAt, body.get("status"));
return ApiResponse.ok(p);
}
@PostMapping("/{id}/published")
@Operation(summary = "발행 완료 처리", description = "body: {url} — 상태를 PUBLISHED 로 바꾸고 URL/시각 기록.")
public ApiResponse<PublishPackage> markPublished(@PathVariable Long id, @RequestBody Map<String, String> body) {
return ApiResponse.ok(publishService.markPublished(id, body.get("url")));
}
@GetMapping
@Operation(summary = "발행 큐 조회", description = "status(DRAFT|READY|PUBLISHED) 필터, 예약일 순.")
public ApiResponse<List<PublishPackage>> list(@RequestParam(required = false) String status) {
return ApiResponse.ok(publishService.list(status));
}
}

View File

@ -0,0 +1,69 @@
package com.hlab.yanalyst.domain.publish;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 발행(배포) 패키지 재가공한 영상을 어디에/언제/어떤 메타데이터로 올릴지 준비하고,
* 발행 결과(URL) 기록한다. 실제 업로드는 수동(또는 추후 플랫폼 API 연동) 여기는 준비·추적 단계.
* ChannelVideo 1:1.
*/
@Entity
@Table(name = "publish_packages",
uniqueConstraints = @UniqueConstraint(columnNames = "channel_video_id"))
@Getter
@Setter
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class PublishPackage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "channel_video_id", nullable = false)
private Long channelVideoId;
@Column(columnDefinition = "TEXT")
private String title;
@Column(columnDefinition = "TEXT")
private String description;
@Column(columnDefinition = "TEXT")
private String hashtags;
/** 대상 플랫폼: YOUTUBE / TIKTOK / REELS 등. */
@Column(length = 100)
private String platform = "YOUTUBE";
/** 발행 예약 일시(선택). */
@Column(name = "scheduled_at")
private LocalDateTime scheduledAt;
/** DRAFT(작성중) / READY(발행대기) / PUBLISHED(발행완료). */
@Column(length = 20)
private String status = "DRAFT";
/** 발행 완료 시 실제 업로드된 URL(수동 기록). */
@Column(name = "published_url", columnDefinition = "TEXT")
private String publishedUrl;
@Column(name = "published_at")
private LocalDateTime publishedAt;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,12 @@
package com.hlab.yanalyst.domain.publish;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface PublishPackageRepository extends JpaRepository<PublishPackage, Long> {
Optional<PublishPackage> findByChannelVideoId(Long channelVideoId);
List<PublishPackage> findByStatus(String status, Sort sort);
}

View File

@ -0,0 +1,66 @@
package com.hlab.yanalyst.domain.publish;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PublishService {
private final PublishPackageRepository repository;
private static final Set<String> ALLOWED_STATUS = Set.of("DRAFT", "READY", "PUBLISHED");
public PublishPackage getByVideo(Long channelVideoId) {
return repository.findByChannelVideoId(channelVideoId).orElse(null);
}
/** 영상별 발행 패키지 upsert. */
@Transactional
public PublishPackage upsert(Long channelVideoId, String title, String description, String hashtags,
String platform, LocalDateTime scheduledAt, String status) {
if (status != null && !ALLOWED_STATUS.contains(status)) {
throw new IllegalArgumentException("허용되지 않은 상태값: " + status + " (가능: " + ALLOWED_STATUS + ")");
}
PublishPackage p = repository.findByChannelVideoId(channelVideoId).orElseGet(() -> {
PublishPackage np = new PublishPackage();
np.setChannelVideoId(channelVideoId);
return np;
});
p.setTitle(title);
p.setDescription(description);
p.setHashtags(hashtags);
if (StringUtils.hasText(platform)) p.setPlatform(platform);
p.setScheduledAt(scheduledAt);
if (StringUtils.hasText(status)) p.setStatus(status);
return repository.save(p);
}
/** 발행 완료 처리(실제 업로드 URL 기록). */
@Transactional
public PublishPackage markPublished(Long id, String url) {
PublishPackage p = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Publish package not found: " + id));
p.setStatus("PUBLISHED");
p.setPublishedUrl(url);
p.setPublishedAt(LocalDateTime.now());
return repository.save(p);
}
/** 발행 큐: 상태(null이면 전체)로 필터, 예약일 → 수정일 순. */
public List<PublishPackage> list(String status) {
Sort sort = Sort.by(Sort.Order.asc("scheduledAt").nullsLast(), Sort.Order.desc("updatedAt"));
if (StringUtils.hasText(status)) {
return repository.findByStatus(status, sort);
}
return repository.findAll(sort);
}
}

View File

@ -0,0 +1,58 @@
package com.hlab.yanalyst.domain.script;
import com.hlab.yanalyst.domain.video.YtVideo;
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "script_gen")
@Getter
@Setter
@NoArgsConstructor
public class ScriptGen {
@Id
@Column(name = "video_id")
private Long videoId;
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "video_id")
private YtVideo video;
@Type(JsonBinaryType.class)
@Column(name = "request_payload_json", columnDefinition = "jsonb")
private String requestPayloadJson;
@Column(name = "response_text", nullable = false, columnDefinition = "TEXT")
private String responseText;
@Column(name = "model_name", length = 100)
private String modelName;
@Column(name = "latency_ms")
private Integer latencyMs;
@Column(name = "status", nullable = false, length = 20)
private String status = "SUCCESS";
@Column(name = "error_msg", columnDefinition = "TEXT")
private String errorMsg;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,6 @@
package com.hlab.yanalyst.domain.script;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ScriptGenRepository extends JpaRepository<ScriptGen, Long> {
}

View File

@ -0,0 +1,64 @@
package com.hlab.yanalyst.domain.video;
import com.hlab.yanalyst.domain.channel.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.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Entity
@Table(name = "videos")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class Video {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String videoId; // YouTube Video ID
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String description;
private String thumbnailUrl;
private String videoUrl;
private Long viewCount;
private Long likeCount;
private LocalDateTime publishedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "channel_id")
private Channel channel;
@CreatedDate
@Column(updatable = false)
private LocalDateTime collectedAt;
@Builder
public Video(String videoId, String title, String description, String thumbnailUrl, String videoUrl, Long viewCount, Long likeCount, LocalDateTime publishedAt, Channel channel) {
this.videoId = videoId;
this.title = title;
this.description = description;
this.thumbnailUrl = thumbnailUrl;
this.videoUrl = videoUrl;
this.viewCount = viewCount;
this.likeCount = likeCount;
this.publishedAt = publishedAt;
this.channel = channel;
}
}

View File

@ -0,0 +1,34 @@
package com.hlab.yanalyst.domain.video;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/videos")
@RequiredArgsConstructor
@Tag(name = "Video API", description = "Video Management API")
public class VideoController {
private final VideoService videoService;
@GetMapping
@Operation(summary = "Get all videos", description = "Retrieve a paginated list of videos.")
public ApiResponse<Page<Video>> getVideos(@PageableDefault(size = 20) Pageable pageable) {
return ApiResponse.ok(videoService.getAllVideos(pageable));
}
@GetMapping("/{id}")
@Operation(summary = "Get video details", description = "Retrieve detailed information of a specific video.")
public ApiResponse<Video> getVideo(@PathVariable Long id) {
return ApiResponse.ok(videoService.getVideo(id));
}
}

View File

@ -0,0 +1,13 @@
package com.hlab.yanalyst.domain.video;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface VideoRepository extends JpaRepository<Video, Long> {
Optional<Video> findByVideoId(String videoId);
boolean existsByVideoId(String videoId);
Page<Video> findAllByOrderByPublishedAtDesc(Pageable pageable);
}

View File

@ -0,0 +1,26 @@
package com.hlab.yanalyst.domain.video;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class VideoService {
private final VideoRepository videoRepository;
public Page<Video> getAllVideos(Pageable pageable) {
return videoRepository.findAllByOrderByPublishedAtDesc(pageable);
}
public Video getVideo(Long id) {
return videoRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Video not found with id: " + id));
}
// Additional methods for create/update will be added as needed
}

View File

@ -0,0 +1,84 @@
package com.hlab.yanalyst.domain.video;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "yt_video")
@Getter
@Setter
@NoArgsConstructor
public class YtVideo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "video_id")
private Long videoId;
@Column(name = "youtube_video_id", nullable = false, unique = true, length = 32)
private String youtubeVideoId;
@Column(name = "video_url", nullable = false, columnDefinition = "TEXT")
private String videoUrl;
@Column(name = "opening_script", columnDefinition = "TEXT")
private String openingScript;
@Column(name = "title", columnDefinition = "TEXT")
private String title;
@Column(name = "channel_title", columnDefinition = "TEXT")
private String channelTitle;
@Column(name = "published_at")
private LocalDateTime publishedAt;
@Column(name = "duration_sec")
private Integer durationSec;
@Column(name = "view_count")
private Long viewCount;
@Column(name = "like_count")
private Long likeCount;
@Column(name = "comment_count")
private Long commentCount;
@Column(name = "subscriber_count")
private Long subscriberCount;
@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;
@Column(name = "thumbnail_url", columnDefinition = "TEXT")
private String thumbnailUrl;
@Column(name = "status", nullable = false, length = 30)
private String status = "CRAWLED";
@Column(name = "last_crawled_at", nullable = false)
private LocalDateTime lastCrawledAt = LocalDateTime.now();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Column(name = "is_completed")
private Boolean isCompleted = false;
}

View File

@ -0,0 +1,11 @@
package com.hlab.yanalyst.domain.video;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface YtVideoRepository extends JpaRepository<YtVideo, Long>, org.springframework.data.jpa.repository.JpaSpecificationExecutor<YtVideo> {
Page<YtVideo> findAllByOrderByLastCrawledAtDesc(Pageable pageable);
java.util.Optional<YtVideo> findByYoutubeVideoId(String youtubeVideoId);
}

View File

@ -0,0 +1,26 @@
package com.hlab.yanalyst.domain.video.dto;
import com.hlab.yanalyst.domain.video.YtVideo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class VideoDetailResponse {
private VideoListResponse metadata;
private String videoUrl;
private Integer durationSec;
private Long likeCount;
private Long commentCount;
private LocalDateTime publishedAt;
public static VideoDetailResponse from(YtVideo video) {
VideoDetailResponse response = new VideoDetailResponse();
response.setMetadata(VideoListResponse.from(video));
response.setVideoUrl(video.getVideoUrl());
response.setDurationSec(video.getDurationSec());
response.setLikeCount(video.getLikeCount());
response.setCommentCount(video.getCommentCount());
response.setPublishedAt(video.getPublishedAt());
return response;
}
}

View File

@ -0,0 +1,38 @@
package com.hlab.yanalyst.domain.video.dto;
import com.hlab.yanalyst.domain.video.YtVideo;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class VideoListResponse {
private Long videoId;
private String youtubeVideoId;
private String title;
private String channelTitle;
private String thumbnailUrl;
private Long viewCount;
private Long subscriberCount;
private BigDecimal viewsPerSubRatio;
private BigDecimal viewsPerHour;
private String status;
private LocalDateTime lastCrawledAt;
public static VideoListResponse from(YtVideo video) {
VideoListResponse response = new VideoListResponse();
response.setVideoId(video.getVideoId());
response.setYoutubeVideoId(video.getYoutubeVideoId());
response.setTitle(video.getTitle());
response.setChannelTitle(video.getChannelTitle());
response.setThumbnailUrl(video.getThumbnailUrl());
response.setViewCount(video.getViewCount());
response.setSubscriberCount(video.getSubscriberCount());
response.setViewsPerSubRatio(video.getViewsPerSubRatio());
response.setViewsPerHour(video.getViewsPerHour());
response.setStatus(video.getStatus());
response.setLastCrawledAt(video.getLastCrawledAt());
return response;
}
}

View File

@ -0,0 +1,47 @@
package com.hlab.yanalyst.global.common;
import lombok.Builder;
import lombok.Getter;
import org.springframework.http.HttpStatus;
import java.time.LocalDateTime;
@Getter
public class ApiResponse<T> {
private final boolean success;
private final String message;
private final T data;
private final LocalDateTime timestamp;
@Builder
private ApiResponse(boolean success, String message, T data) {
this.success = success;
this.message = message;
this.data = data;
this.timestamp = LocalDateTime.now();
}
public static <T> ApiResponse<T> ok(T data) {
return ApiResponse.<T>builder()
.success(true)
.message("Success")
.data(data)
.build();
}
public static <T> ApiResponse<T> created(T data) {
return ApiResponse.<T>builder()
.success(true)
.message("Created")
.data(data)
.build();
}
public static ApiResponse<Void> error(String message) {
return ApiResponse.<Void>builder()
.success(false)
.message(message)
.build();
}
}

View File

@ -0,0 +1,60 @@
package com.hlab.yanalyst.global.config;
import com.p6spy.engine.spy.appender.MessageFormattingStrategy;
import org.hibernate.engine.jdbc.internal.FormatStyle;
import org.springframework.context.annotation.Configuration;
import java.util.Locale;
@org.springframework.stereotype.Component
@org.springframework.context.annotation.Primary
public class P6SpyFormatter implements MessageFormattingStrategy {
@Override
public String formatMessage(int connectionId, String now, long elapsed, String category, String prepared, String sql, String url) {
String formattedSql = formatSql(category, sql);
// Return empty if nothing to log
if (formattedSql.trim().isEmpty() && !Category.RESULT.getName().equals(category)) {
return "";
}
return String.format(Locale.ROOT, "\n" +
"╔════════════════════════════════════════════════════════════════════════════════════════════════════\n" +
"║ SQL Execution Summary\n" +
"╠════════════════════════════════════════════════════════════════════════════════════════════════════\n" +
"║ Time : %d ms\n" +
"║ Connection : %d\n" +
"║ Category : %s\n" +
"╚════════════════════════════════════════════════════════════════════════════════════════════════════\n" +
"%s\n",
elapsed, connectionId, category, formattedSql);
}
private String formatSql(String category, String sql) {
if (sql == null || sql.trim().isEmpty()) {
return "";
}
// Pretty print SQL Statements
if (Category.STATEMENT.getName().equals(category)) {
String tmpsql = sql.trim().toLowerCase(Locale.ROOT);
if (tmpsql.startsWith("create") || tmpsql.startsWith("alter") || tmpsql.startsWith("comment")) {
return FormatStyle.DDL.getFormatter().format(sql);
} else {
return FormatStyle.BASIC.getFormatter().format(sql);
}
}
// Pass through results or other categories
return sql;
}
enum Category {
STATEMENT("statement"),
RESULT("result");
private final String name;
Category(String name){ this.name = name; }
public String getName(){ return name; }
}
}

View File

@ -0,0 +1,24 @@
package com.hlab.yanalyst.global.config;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.connectTimeout(Duration.ofSeconds(20))
.readTimeout(Duration.ofSeconds(120))
.additionalInterceptors((request, body, execution) -> {
request.getHeaders().add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
return execution.execute(request, body);
})
.build();
}
}

View File

@ -0,0 +1,19 @@
package com.hlab.yanalyst.global.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("Youtube Analyst API")
.description("Youtube Data Analysis Service API Documentation")
.version("v1.0.0"));
}
}

View File

@ -0,0 +1,24 @@
package com.hlab.yanalyst.global.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*") // Allow all origins including h-lab.tolag.shop
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
@Override
public void addResourceHandlers(org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/");
}
}

View File

@ -0,0 +1,44 @@
package com.hlab.yanalyst.global.error;
import com.hlab.yanalyst.global.common.ApiResponse;
import jakarta.persistence.EntityNotFoundException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleEntityNotFound(EntityNotFoundException e) {
log.error("EntityNotFoundException: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error(e.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException e) {
log.error("IllegalArgumentException: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationExceptions(MethodArgumentNotValidException e) {
String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
log.error("ValidationException: {}", errorMessage);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(errorMessage));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
log.error("Unhandled Exception: ", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Internal Server Error"));
}
}

View File

@ -0,0 +1,117 @@
package com.hlab.yanalyst.global.schedule;
import com.hlab.yanalyst.domain.channel.Channel;
import com.hlab.yanalyst.domain.channel.ChannelRepository;
import com.hlab.yanalyst.domain.channel.ChannelService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 등록된 채널의 신규 영상을 주기적으로 자동 수집한다.
* 쿼터 가드로 일일 예산을 넘지 않도록 채널 단위로 차단한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ScheduledCollectionService {
private final ChannelRepository channelRepository;
private final ChannelService channelService;
private final YoutubeQuotaGuard quotaGuard;
@Value("${hlab.scheduler.channel-collection.enabled:true}")
private boolean enabled;
@Value("${hlab.scheduler.channel-snapshot.enabled:true}")
private boolean snapshotEnabled;
/** 채널 1개 수집의 추정 쿼터 소비량(playlistItems + videos.list, 대략치). */
private static final long EST_UNITS_PER_CHANNEL = 12;
/** 채널 통계 스냅샷 1건의 추정 쿼터(channels.list). */
private static final long EST_UNITS_PER_SNAPSHOT = 1;
@Scheduled(cron = "${hlab.scheduler.channel-collection.cron:0 0 4 * * *}")
public void scheduledRun() {
if (!enabled) {
log.info("[Scheduler] 채널 자동 수집 비활성화됨 (hlab.scheduler.channel-collection.enabled=false)");
return;
}
Map<String, Object> result = runChannelCollection();
log.info("[Scheduler] 채널 자동 수집 완료: {}", result);
}
/** 수동/스케줄 공용. 모든 등록 채널을 쿼터 한도 내에서 수집하고 결과 요약을 반환. */
public Map<String, Object> runChannelCollection() {
List<Channel> channels = channelRepository.findAll();
int ok = 0, failed = 0, skippedByQuota = 0;
for (Channel c : channels) {
if (!quotaGuard.tryConsume(EST_UNITS_PER_CHANNEL)) {
skippedByQuota++;
log.warn("[Scheduler] 쿼터 예산 소진 — 채널 {} 이후 건너뜀 (잔여 {} units)", c.getId(), quotaGuard.remaining());
continue;
}
try {
channelService.collectChannelVideos(c.getId());
ok++;
} catch (Exception e) {
failed++;
log.error("[Scheduler] 채널 {} 수집 실패", c.getId(), e);
}
}
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("totalChannels", channels.size());
summary.put("collected", ok);
summary.put("failed", failed);
summary.put("skippedByQuota", skippedByQuota);
summary.put("quotaRemaining", quotaGuard.remaining());
return summary;
}
@Scheduled(cron = "${hlab.scheduler.channel-snapshot.cron:0 0 3 * * *}")
public void scheduledSnapshot() {
if (!snapshotEnabled) {
log.info("[Scheduler] 채널 성장 스냅샷 비활성화됨");
return;
}
Map<String, Object> result = runChannelSnapshot();
log.info("[Scheduler] 채널 성장 스냅샷 완료: {}", result);
}
/** 모든 채널의 통계를 갱신하며 일별 성장 스냅샷을 기록. */
public Map<String, Object> runChannelSnapshot() {
List<Channel> channels = channelRepository.findAll();
int ok = 0, failed = 0, skippedByQuota = 0;
for (Channel c : channels) {
if (!quotaGuard.tryConsume(EST_UNITS_PER_SNAPSHOT)) {
skippedByQuota++;
continue;
}
try {
channelService.refreshChannelStats(c.getId());
ok++;
} catch (Exception e) {
failed++;
log.error("[Scheduler] 채널 {} 스냅샷 실패", c.getId(), e);
}
}
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("totalChannels", channels.size());
summary.put("snapshotted", ok);
summary.put("failed", failed);
summary.put("skippedByQuota", skippedByQuota);
summary.put("quotaRemaining", quotaGuard.remaining());
return summary;
}
}

View File

@ -0,0 +1,40 @@
package com.hlab.yanalyst.global.schedule;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/scheduler")
@RequiredArgsConstructor
@Tag(name = "Scheduler API", description = "정기 자동 수집 수동 실행 / 쿼터 상태")
public class SchedulerController {
private final ScheduledCollectionService scheduledCollectionService;
private final YoutubeQuotaGuard quotaGuard;
@PostMapping("/run-channel-collection")
@Operation(summary = "채널 자동 수집 즉시 실행", description = "등록된 모든 채널을 쿼터 한도 내에서 수집한다.")
public ApiResponse<Map<String, Object>> runNow() {
return ApiResponse.ok(scheduledCollectionService.runChannelCollection());
}
@PostMapping("/run-channel-snapshot")
@Operation(summary = "채널 성장 스냅샷 즉시 실행", description = "모든 채널 통계를 갱신하며 오늘자 성장 스냅샷을 기록한다.")
public ApiResponse<Map<String, Object>> runSnapshot() {
return ApiResponse.ok(scheduledCollectionService.runChannelSnapshot());
}
@GetMapping("/quota")
@Operation(summary = "YouTube 쿼터 상태", description = "오늘 사용/잔여 추정치.")
public ApiResponse<Map<String, Object>> quota() {
return ApiResponse.ok(Map.of("used", quotaGuard.used(), "remaining", quotaGuard.remaining()));
}
}

View File

@ -0,0 +1,49 @@
package com.hlab.yanalyst.global.schedule;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
/**
* YouTube Data API 일일 쿼터 가드(추정 기반).
* 자동 수집 배치 작업이 하루 쿼터(기본 10,000 units) 넘기지 않도록 예산을 관리한다.
* 자정(서버 시각) 넘기면 사용량을 리셋한다. 대화형 검색은 아직 계측 대상이 아님.
*/
@Component
public class YoutubeQuotaGuard {
@Value("${hlab.youtube.daily-quota:10000}")
private long dailyQuota;
private long used = 0;
private LocalDate day = LocalDate.now();
private synchronized void rollover() {
LocalDate today = LocalDate.now();
if (!today.equals(day)) {
day = today;
used = 0;
}
}
/** units 만큼 소비를 시도. 예산을 넘기면 false(소비하지 않음). */
public synchronized boolean tryConsume(long units) {
rollover();
if (used + units > dailyQuota) {
return false;
}
used += units;
return true;
}
public synchronized long remaining() {
rollover();
return Math.max(0, dailyQuota - used);
}
public synchronized long used() {
rollover();
return used;
}
}

View File

@ -0,0 +1,180 @@
package com.hlab.yanalyst.service;
import com.hlab.yanalyst.domain.opal.*;
import com.hlab.yanalyst.domain.script.ScriptGen;
import com.hlab.yanalyst.domain.script.ScriptGenRepository;
import com.hlab.yanalyst.domain.video.YtVideo;
import com.hlab.yanalyst.domain.video.YtVideoRepository;
import com.hlab.yanalyst.service.external.ExternalApiService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional
public class AnalysisWorkflowService {
private final YtVideoRepository ytVideoRepository;
private final ScriptGenRepository scriptGenRepository;
private final OpalDraftRepository opalDraftRepository;
private final OpalFinalRepository opalFinalRepository;
private final OpalFinalAssetRepository opalFinalAssetRepository;
private final ExternalApiService externalApiService;
private final ObjectMapper objectMapper;
public ScriptGen generateScript(Long videoId) {
YtVideo video = ytVideoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found"));
String scriptContent = externalApiService.generateScript(video.getVideoUrl());
ScriptGen scriptGen = scriptGenRepository.findById(videoId).orElse(new ScriptGen());
if (scriptGen.getVideoId() == null) {
scriptGen.setVideo(video);
}
scriptGen.setResponseText(scriptContent);
scriptGen.setStatus("SUCCESS");
// Update video status
video.setStatus("SCRIPT_READY");
return scriptGenRepository.save(scriptGen);
}
public OpalDraft generateDraft(Long videoId, String feedback, String mode) {
YtVideo video = ytVideoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found"));
ScriptGen scriptGen = scriptGenRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Script not found"));
var draftDto = externalApiService.generateOpalDraft(scriptGen.getResponseText(), feedback, mode);
OpalDraft draft = new OpalDraft();
draft.setVideo(video);
draft.setOldScriptSummary(draftDto.getOldScriptSummary());
draft.setNewScriptSummary(draftDto.getNewScriptSummary());
// For compatibility, we can concatenate or just leave responseText empty/null, or use New Summary as main response
draft.setResponseText(draftDto.getNewScriptSummary());
draft.setUserFeedback(feedback);
Integer maxVersion = opalDraftRepository.findByVideo_VideoIdOrderByVersionNoDesc(videoId)
.stream().findFirst().map(OpalDraft::getVersionNo).orElse(0);
draft.setVersionNo(maxVersion + 1);
draft.setStatus("SUCCESS");
video.setStatus("DRAFTING");
return opalDraftRepository.save(draft);
}
public OpalFinal acceptDraft(Long videoId, Long draftId) {
YtVideo video = ytVideoRepository.findById(videoId).orElseThrow();
OpalDraft draft = opalDraftRepository.findById(draftId).orElseThrow();
if (!draft.getVideo().getVideoId().equals(videoId)) {
throw new IllegalArgumentException("Draft does not match video");
}
// Deactivate existing active final
opalFinalRepository.findByVideo_VideoIdAndIsActiveTrue(videoId)
.ifPresent(existing -> {
existing.setIsActive(false);
opalFinalRepository.save(existing);
});
// Mark draft accepted
draft.setIsAccepted(true);
draft.setAcceptedAt(LocalDateTime.now());
opalDraftRepository.save(draft);
OpalFinal opalFinal = new OpalFinal();
opalFinal.setVideo(video);
opalFinal.setDraft(draft);
opalFinal.setFinalScriptText(draft.getResponseText());
opalFinal.setIsActive(true);
return opalFinalRepository.save(opalFinal);
}
public OpalFinalAsset generateFinalAsset(Long videoId) {
YtVideo video = ytVideoRepository.findById(videoId).orElseThrow();
OpalFinal activeFinal = opalFinalRepository.findByVideo_VideoIdAndIsActiveTrue(videoId)
.orElse(null);
if (activeFinal == null) {
OpalDraft latestDraft = opalDraftRepository.findByVideo_VideoIdOrderByVersionNoDesc(videoId)
.stream().findFirst()
.orElseThrow(() -> new IllegalArgumentException("No active final script and no drafts found. Please generate a draft first."));
activeFinal = new OpalFinal();
activeFinal.setVideo(video);
activeFinal.setDraft(latestDraft);
activeFinal.setIsActive(true);
activeFinal.setFinalScriptText("");
activeFinal = opalFinalRepository.save(activeFinal);
}
// 1. Fetch updated final script from external source (GDoc)
String updatedFinalScript = externalApiService.fetchFinalScript();
// 2. Update and save active final script
activeFinal.setFinalScriptText(updatedFinalScript);
opalFinalRepository.save(activeFinal);
// 3. Generate asset metadata
var assetMap = externalApiService.generateFinalAsset(activeFinal.getFinalScriptText());
OpalFinalAsset asset = new OpalFinalAsset();
asset.setOpalFinal(activeFinal);
try {
asset.setAssetJson(objectMapper.writeValueAsString(assetMap));
asset.setTitle((String) assetMap.get("title"));
asset.setSummary((String) assetMap.get("summary"));
asset.setTimeline(objectMapper.writeValueAsString(assetMap.get("timeline")));
asset.setVideoPrompt((String) assetMap.get("video_prompt"));
asset.setImageUrls(objectMapper.writeValueAsString(assetMap.get("image_urls")));
} catch (Exception e) {
throw new RuntimeException("JSON processing error", e);
}
video.setStatus("FINALIZED");
return opalFinalAssetRepository.save(asset);
}
public boolean toggleComplete(Long videoId) {
YtVideo video = ytVideoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found"));
boolean newState = video.getIsCompleted() == null ? true : !video.getIsCompleted();
video.setIsCompleted(newState);
ytVideoRepository.save(video);
return newState;
}
public void updateFinalAsset(Long videoId, String newText) {
OpalFinal opalFinal = opalFinalRepository.findByVideo_VideoIdAndIsActiveTrue(videoId)
.orElseThrow(() -> new IllegalArgumentException("No active final asset found for videoId: " + videoId));
opalFinal.setFinalScriptText(newText);
opalFinalRepository.save(opalFinal);
}
public void generateOpening(Long videoId, String docId) {
YtVideo video = ytVideoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found"));
String openingScript = externalApiService.fetchOpeningScript(docId);
video.setOpeningScript(openingScript);
ytVideoRepository.save(video);
}
public void resetOpening(Long videoId) {
YtVideo video = ytVideoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found"));
video.setOpeningScript(null);
ytVideoRepository.save(video);
}
}

View File

@ -0,0 +1,487 @@
package com.hlab.yanalyst.service;
import com.hlab.yanalyst.domain.opal.OpalDraftRepository;
import com.hlab.yanalyst.domain.opal.OpalFinal;
import com.hlab.yanalyst.domain.opal.OpalFinalAssetRepository;
import com.hlab.yanalyst.domain.opal.OpalFinalRepository;
import com.hlab.yanalyst.domain.script.ScriptGenRepository;
import com.hlab.yanalyst.domain.video.YtVideo;
import com.hlab.yanalyst.domain.video.YtVideoRepository;
import com.hlab.yanalyst.web.dto.*;
import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class YtVideoService {
private final YtVideoRepository ytVideoRepository;
private final ScriptGenRepository scriptGenRepository;
private final OpalDraftRepository opalDraftRepository;
private final OpalFinalRepository opalFinalRepository;
private final OpalFinalAssetRepository opalFinalAssetRepository;
private final org.springframework.web.client.RestTemplate restTemplate;
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
@org.springframework.beans.factory.annotation.Value("${youtube.api.key}")
private String youtubeApiKey;
@Transactional
public YtVideo saveVideoFromUrl(String url) {
String videoId = extractVideoId(url);
// Check if exists
java.util.Optional<YtVideo> existing = ytVideoRepository.findByYoutubeVideoId(videoId);
if (existing.isPresent()) {
throw new IllegalArgumentException("Video already exists with ID: " + videoId);
}
String apiUrl = "https://www.googleapis.com/youtube/v3/videos";
org.springframework.web.util.UriComponentsBuilder builder = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(apiUrl)
.queryParam("part", "snippet,statistics,contentDetails")
.queryParam("id", videoId)
.queryParam("key", youtubeApiKey);
try {
com.fasterxml.jackson.databind.JsonNode root = restTemplate.getForObject(builder.toUriString(), com.fasterxml.jackson.databind.JsonNode.class);
com.fasterxml.jackson.databind.JsonNode items = root.path("items");
if (items.isEmpty()) {
throw new IllegalArgumentException("Video not found for ID: " + videoId);
}
com.fasterxml.jackson.databind.JsonNode item = items.get(0);
com.fasterxml.jackson.databind.JsonNode snippet = item.get("snippet");
com.fasterxml.jackson.databind.JsonNode statistics = item.get("statistics");
com.fasterxml.jackson.databind.JsonNode contentDetails = item.get("contentDetails");
YtVideo video = new YtVideo();
video.setYoutubeVideoId(videoId);
video.setVideoUrl("https://www.youtube.com/watch?v=" + videoId);
video.setTitle(snippet.get("title").asText());
video.setChannelTitle(snippet.get("channelTitle").asText());
video.setThumbnailUrl(snippet.get("thumbnails").has("maxres")
? snippet.get("thumbnails").get("maxres").get("url").asText()
: snippet.get("thumbnails").get("high").get("url").asText());
video.setPublishedAt(LocalDateTime.parse(snippet.get("publishedAt").asText(), java.time.format.DateTimeFormatter.ISO_DATE_TIME));
if (statistics.has("viewCount")) video.setViewCount(Long.parseLong(statistics.get("viewCount").asText()));
if (statistics.has("likeCount")) video.setLikeCount(Long.parseLong(statistics.get("likeCount").asText()));
if (statistics.has("commentCount")) video.setCommentCount(Long.parseLong(statistics.get("commentCount").asText()));
String duration = contentDetails.get("duration").asText();
video.setDurationSec((int) java.time.Duration.parse(duration).getSeconds());
// Set simple defaults for calculated fields
video.setViewsPerHour(java.math.BigDecimal.ZERO);
video.setViewsPerSubRatio(java.math.BigDecimal.ZERO);
video.setSubscriberCount(0L); // We don't have channel stats here in one call
video.setStatus("CRAWLED");
return ytVideoRepository.save(video);
} catch (Exception e) {
throw new RuntimeException("Failed to fetch video details", e);
}
}
private String extractVideoId(String url) {
// Simple extraction for v= parameter or short url
if (url.contains("v=")) {
String[] parts = url.split("v=");
if (parts.length > 1) {
String id = parts[1];
int ampIndex = id.indexOf("&");
if (ampIndex != -1) {
return id.substring(0, ampIndex);
}
return id;
}
} else if (url.contains("youtu.be/")) {
String[] parts = url.split("youtu.be/");
if (parts.length > 1) {
return parts[1].split("\\?")[0];
}
}
return url; // Assume ID if not URL format, or fail later
}
public Page<VideoResponse> getVideos(VideoSearchCondition condition, Pageable pageable) {
Specification<YtVideo> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (StringUtils.hasText(condition.getStatus())) {
predicates.add(cb.equal(root.get("status"), condition.getStatus()));
}
if (StringUtils.hasText(condition.getKeyword())) {
String likePattern = "%" + condition.getKeyword() + "%";
predicates.add(cb.or(
cb.like(root.get("title"), likePattern),
cb.like(root.get("channelTitle"), likePattern)
));
}
if (condition.getStartDate() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("lastCrawledAt"), condition.getStartDate().atStartOfDay()));
}
if (condition.getEndDate() != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("lastCrawledAt"), condition.getEndDate().atTime(23, 59, 59)));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
return ytVideoRepository.findAll(spec, pageable).map(VideoResponse::new);
}
public VideoDetailResponse getVideoDetail(Long videoId) {
YtVideo video = ytVideoRepository.findById(videoId)
.orElseThrow(() -> new IllegalArgumentException("Video not found"));
VideoDetailResponse response = new VideoDetailResponse(video);
scriptGenRepository.findById(videoId).ifPresent(scriptGen -> {
response.setScriptContent(scriptGen.getResponseText());
response.setScriptStatus(scriptGen.getStatus());
});
return response;
}
public List<OpalDraftResponse> getDrafts(Long videoId) {
return opalDraftRepository.findByVideo_VideoIdOrderByVersionNoDesc(videoId)
.stream()
.map(OpalDraftResponse::new)
.collect(Collectors.toList());
}
public FinalAssetResponse getFinalAsset(Long videoId) {
return opalFinalRepository.findByVideo_VideoIdAndIsActiveTrue(videoId)
.map(opalFinal -> {
var asset = opalFinalAssetRepository.findById(opalFinal.getFinalId()).orElse(null);
return new FinalAssetResponse(opalFinal, asset);
})
.orElse(null);
}
@Transactional
public void updateFinalAsset(Long videoId, String newText) {
OpalFinal opalFinal = opalFinalRepository.findByVideo_VideoIdAndIsActiveTrue(videoId)
.orElseThrow(() -> new IllegalArgumentException("No active final asset found for videoId: " + videoId));
opalFinal.setFinalScriptText(newText);
// opalFinalRepository.save(opalFinal); // Transactional handles save
}
public com.hlab.yanalyst.web.dto.YoutubeSearchPageDto searchYoutubeVideos(YoutubeSearchCondition condition) {
List<YoutubeSearchResultDto> results = new ArrayList<>();
java.util.Map<String, String> nextTokens = new java.util.HashMap<>();
List<String> regions = condition.getRegions() != null && !condition.getRegions().isEmpty()
? condition.getRegions()
: List.of("JP", "US");
List<String> channelIds = condition.getChannelIds();
boolean isChannelSearch = channelIds != null && !channelIds.isEmpty();
List<String> searchTargets = isChannelSearch ? channelIds : regions;
String publishedAfter = null;
if (condition.getPeriodDays() != null && condition.getPeriodDays() > 0) {
java.time.ZonedDateTime date = java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC).minusDays(condition.getPeriodDays());
publishedAfter = date.format(java.time.format.DateTimeFormatter.ISO_INSTANT);
}
String duration = "short";
if ("LONG_FORM".equalsIgnoreCase(condition.getFormat())) {
duration = "long";
}
String searchApiUrl = "https://www.googleapis.com/youtube/v3/search";
List<com.fasterxml.jackson.databind.JsonNode> allItems = new ArrayList<>();
for (String target : searchTargets) {
String order = StringUtils.hasText(condition.getKeyword()) ? "relevance" : "date";
org.springframework.web.util.UriComponentsBuilder builder = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(searchApiUrl)
.queryParam("part", "snippet")
.queryParam("maxResults", 50)
.queryParam("type", "video")
.queryParam("order", order)
.queryParam("key", youtubeApiKey)
.queryParam("videoDuration", duration);
if (isChannelSearch) {
builder.queryParam("channelId", target);
} else {
builder.queryParam("regionCode", target);
String lang = "en";
if ("JP".equalsIgnoreCase(target)) lang = "ja";
else if ("KR".equalsIgnoreCase(target)) lang = "ko";
builder.queryParam("relevanceLanguage", lang);
}
if (StringUtils.hasText(condition.getKeyword())) {
builder.queryParam("q", condition.getKeyword());
} else {
builder.queryParam("q", " ");
}
if (publishedAfter != null) {
builder.queryParam("publishedAfter", publishedAfter);
}
if (condition.getPageTokens() != null && condition.getPageTokens().containsKey(target)) {
builder.queryParam("pageToken", condition.getPageTokens().get(target));
}
try {
com.fasterxml.jackson.databind.JsonNode root = restTemplate.getForObject(builder.build().encode().toUri(), com.fasterxml.jackson.databind.JsonNode.class);
if (root != null) {
if (root.has("nextPageToken")) {
nextTokens.put(target, root.get("nextPageToken").asText());
}
if (root.has("items")) {
for (com.fasterxml.jackson.databind.JsonNode item : root.get("items")) {
allItems.add(item);
}
}
}
} catch (Exception e) {
// Ignore errors for individual target
}
}
java.util.Map<String, YoutubeSearchResultDto> uniqueVideos = new java.util.HashMap<>();
for (com.fasterxml.jackson.databind.JsonNode item : allItems) {
String videoId = item.get("id").get("videoId").asText();
if (!uniqueVideos.containsKey(videoId)) {
com.fasterxml.jackson.databind.JsonNode snippet = item.get("snippet");
YoutubeSearchResultDto dto = new YoutubeSearchResultDto();
dto.setVideoId(videoId);
dto.setTitle(snippet.get("title").asText());
dto.setChannelId(snippet.get("channelId").asText());
dto.setChannelTitle(snippet.get("channelTitle").asText());
String publishedAtStr = snippet.get("publishedAt").asText();
dto.setPublishedAt(LocalDateTime.parse(publishedAtStr, java.time.format.DateTimeFormatter.ISO_DATE_TIME));
if (snippet.has("thumbnails") && snippet.get("thumbnails").has("high")) {
dto.setThumbnailUrl(snippet.get("thumbnails").get("high").get("url").asText());
} else if (snippet.has("thumbnails") && snippet.get("thumbnails").has("default")) {
dto.setThumbnailUrl(snippet.get("thumbnails").get("default").get("url").asText());
}
uniqueVideos.put(videoId, dto);
}
}
results = new ArrayList<>(uniqueVideos.values());
if (results.isEmpty()) {
com.hlab.yanalyst.web.dto.YoutubeSearchPageDto pageDto = new com.hlab.yanalyst.web.dto.YoutubeSearchPageDto();
pageDto.setItems(results);
pageDto.setNextTokens(nextTokens);
return pageDto;
}
// 2. 조회수 길이 가져오기 (videos API)
// 최대 50개씩 나눠서 요청 (api limit)
List<List<YoutubeSearchResultDto>> partitionedResults = new ArrayList<>();
for (int i = 0; i < results.size(); i += 50) {
partitionedResults.add(results.subList(i, Math.min(i + 50, results.size())));
}
String videoApiUrl = "https://www.googleapis.com/youtube/v3/videos";
java.util.Set<String> idsToRemove = new java.util.HashSet<>();
for (List<YoutubeSearchResultDto> batch : partitionedResults) {
String videoIds = batch.stream().map(YoutubeSearchResultDto::getVideoId).collect(Collectors.joining(","));
try {
org.springframework.web.util.UriComponentsBuilder vBuilder = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(videoApiUrl)
.queryParam("part", "statistics,contentDetails,snippet")
.queryParam("id", videoIds)
.queryParam("key", youtubeApiKey);
com.fasterxml.jackson.databind.JsonNode vRoot = restTemplate.getForObject(vBuilder.build().encode().toUri(), com.fasterxml.jackson.databind.JsonNode.class);
if (vRoot != null && vRoot.has("items")) {
for (com.fasterxml.jackson.databind.JsonNode item : vRoot.get("items")) {
String vId = item.get("id").asText();
long viewCount = 0;
if (item.has("statistics") && item.get("statistics").has("viewCount")) {
viewCount = item.get("statistics").get("viewCount").asLong();
}
int durSec = 0;
if (item.has("contentDetails") && item.get("contentDetails").has("duration")) {
String durStr = item.get("contentDetails").get("duration").asText();
durSec = (int) java.time.Duration.parse(durStr).getSeconds();
}
String titleForTags = "";
String descForTags = "";
if (item.has("snippet")) {
if (item.get("snippet").has("title")) titleForTags = item.get("snippet").get("title").asText();
if (item.get("snippet").has("description")) descForTags = item.get("snippet").get("description").asText();
}
java.util.List<String> tags = new java.util.ArrayList<>();
java.util.regex.Matcher m = java.util.regex.Pattern.compile("#[^\\s#]+").matcher(titleForTags + " " + descForTags);
while (m.find()) {
String tag = m.group();
if (!tags.contains(tag)) {
tags.add(tag);
}
}
long finalViewCount = viewCount;
int finalDurSec = durSec;
results.stream().filter(r -> r.getVideoId().equals(vId)).forEach(r -> {
r.setViewCount(finalViewCount);
r.setDurationSec(finalDurSec);
r.setHashtags(tags);
});
// Strict filter based on format (65 seconds for true Shorts)
boolean isShorts = "SHORTS".equalsIgnoreCase(condition.getFormat());
if (isShorts && durSec > 65) {
idsToRemove.add(vId);
} else if (!isShorts && durSec <= 65) {
idsToRemove.add(vId);
}
}
}
} catch (Exception e) {
// Ignore
}
}
results.removeIf(r -> idsToRemove.contains(r.getVideoId()));
// 3. 채널 구독자 국가 정보 가져오기 (channels API)
java.util.Set<String> resultChannelIds = results.stream().map(YoutubeSearchResultDto::getChannelId).collect(Collectors.toSet());
List<String> channelIdList = new ArrayList<>(resultChannelIds);
List<List<String>> partitionedChannels = new ArrayList<>();
for (int i = 0; i < channelIdList.size(); i += 50) {
partitionedChannels.add(channelIdList.subList(i, Math.min(i + 50, channelIdList.size())));
}
String channelApiUrl = "https://www.googleapis.com/youtube/v3/channels";
java.util.Set<String> channelIdsToRemove = new java.util.HashSet<>();
for (List<String> batch : partitionedChannels) {
String cIdsStr = String.join(",", batch);
try {
org.springframework.web.util.UriComponentsBuilder cBuilder = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(channelApiUrl)
.queryParam("part", "statistics,snippet")
.queryParam("id", cIdsStr)
.queryParam("key", youtubeApiKey);
com.fasterxml.jackson.databind.JsonNode cRoot = restTemplate.getForObject(cBuilder.build().encode().toUri(), com.fasterxml.jackson.databind.JsonNode.class);
if (cRoot != null && cRoot.has("items")) {
for (com.fasterxml.jackson.databind.JsonNode item : cRoot.get("items")) {
String cId = item.get("id").asText();
long subCount = 0;
if (item.has("statistics") && item.get("statistics").has("subscriberCount")) {
subCount = item.get("statistics").get("subscriberCount").asLong();
}
String cCountry = "UNKNOWN";
if (item.has("snippet") && item.get("snippet").has("country")) {
cCountry = item.get("snippet").get("country").asText();
}
long finalSubCount = subCount;
String finalCountry = cCountry;
results.stream().filter(r -> r.getChannelId().equals(cId)).forEach(r -> {
r.setSubscriberCount(finalSubCount);
r.setChannelCountry(finalCountry);
});
// 지역 기반 필터링
boolean isSearchEmpty = !StringUtils.hasText(condition.getKeyword());
boolean keep = false;
if (isChannelSearch) {
keep = true;
} else if (!"UNKNOWN".equals(cCountry)) {
for (String region : regions) {
if (region.equalsIgnoreCase(cCountry)) {
keep = true;
break;
}
}
} else {
if (!isSearchEmpty) {
keep = true; // 검색어가 있으면 유튜브 자체 필터링 신뢰
} else {
// 검색어가 없을 글로벌 영상(영어) 국가 랭킹을 도배하는 방지 (언어 스크립트 휴리스틱)
String title = "";
for (YoutubeSearchResultDto dto : results) {
if (dto.getChannelId().equals(cId)) {
title = dto.getTitle();
break;
}
}
boolean matched = false;
if (regions.contains("KR") && title.matches(".*[가-힣].*")) matched = true;
if (regions.contains("JP") && title.matches(".*[ぁ-んァ-ン一-龥].*")) matched = true;
if (!matched && (regions.contains("US") || regions.isEmpty())) {
// US is selected (or no region selected). Allow if it DOES NOT contain obvious non-English scripts.
boolean hasForeign = false;
try {
hasForeign = title.matches(".*[\\p{InDevanagari}\\p{InCyrillic}\\p{InArabic}\\p{InThai}\\p{InBengali}\\p{InTamil}\\p{InTelugu}].*");
} catch (Exception e) {
// Ignore regex syntax errors in older Java versions, fallback
}
if (!regions.contains("KR") && title.matches(".*[가-힣].*")) hasForeign = true;
if (!regions.contains("JP") && title.matches(".*[ぁ-んァ-ン一-龥].*")) hasForeign = true;
if (!hasForeign) {
matched = true;
}
}
keep = matched;
}
}
if (!keep) {
channelIdsToRemove.add(cId);
}
}
}
} catch (Exception e) {
// Ignore
}
}
results.removeIf(r -> channelIdsToRemove.contains(r.getChannelId()));
// 조회수 내림차순 정렬
results.sort((v1, v2) -> Long.compare(
v2.getViewCount() != null ? v2.getViewCount() : 0L,
v1.getViewCount() != null ? v1.getViewCount() : 0L
));
com.hlab.yanalyst.web.dto.YoutubeSearchPageDto pageDto = new com.hlab.yanalyst.web.dto.YoutubeSearchPageDto();
pageDto.setItems(results);
pageDto.setNextTokens(nextTokens);
return pageDto;
}
}

View File

@ -0,0 +1,14 @@
package com.hlab.yanalyst.service.external;
import org.springframework.stereotype.Service;
import com.hlab.yanalyst.domain.opal.dto.OpalDraftResponseDto;
import java.util.Map;
public interface ExternalApiService {
String generateScript(String videoUrl);
OpalDraftResponseDto generateOpalDraft(String script, String feedback, String mode);
String fetchFinalScript();
String fetchOpeningScript(String docId);
Map<String, Object> generateFinalAsset(String finalScript);
}

View File

@ -0,0 +1,262 @@
package com.hlab.yanalyst.service.external;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Primary;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp;
import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.util.store.FileDataStoreFactory;
import com.google.api.services.docs.v1.Docs;
import com.google.api.services.docs.v1.DocsScopes;
import com.google.api.services.docs.v1.model.*;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@Primary
@RequiredArgsConstructor
public class ExternalApiServiceImpl implements ExternalApiService {
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@Override
public String generateScript(String videoUrl) {
String apiUrl = "http://h-python.tolag.shop/transcript";
log.info("Requesting transcript for URL: {}", videoUrl);
try {
Map<String, String> requestBody = Collections.singletonMap("url", videoUrl);
ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, requestBody, String.class);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
ScriptResponseDto scriptDto = objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
return scriptDto.getTranscript();
} else {
log.error("Failed to fetch script from Python API. Status: {}", response.getStatusCode());
return generateFallbackScript(videoUrl, "API returned status: " + response.getStatusCode());
}
} catch (Exception e) {
log.error("Error calling Python API", e);
// Fallback to prevent blocking the workflow
return generateFallbackScript(videoUrl, e.getMessage());
}
}
private String generateFallbackScript(String url, String errorMsg) {
return String.format("""
[SYSTEM NOTICE: Failed to retrieve real transcript due to external API error.]
[Error Detail: %s]
[Fallback Script for %s]
00:00 - Introduction
Hello, this is a placeholder transcript generated by the system because the external Python API (YouTube Transcript) is currently blocked or unavailable.
00:15 - Main Topic
Usually, real content would appear here. Since YouTube blocks known cloud IPs, this is a common issue with scraping services.
00:30 - Conclusion
You can proceed to generate Opal Drafts and Final Assets using this placeholder text to test the rest of the intended workflow.
""", errorMsg, url);
}
@Override
public com.hlab.yanalyst.domain.opal.dto.OpalDraftResponseDto generateOpalDraft(String script, String feedback, String mode) {
// Determine Doc ID based on mode
String docId;
if ("STRUCTURE_CHANGE".equals(mode)) {
docId = "11eFwYXm1Ld2vZUrOHvJZEmN69KDXvQygEysdXWr8-W4";
} else {
// "TRUE_STORY" (Default)
docId = "1EHpn1GNregte6jTs43ycUUVqy6oljIPj6dm-uIXkcZc";
}
log.info("Fetching Opal draft (summary) using Google Docs API from Doc ID: {} (Mode: {})", docId, mode);
try {
// 1. Fetch content using API
String text = readGoogleDoc(docId);
String oldSummaryMarker = "old_script_summary";
String newSummaryMarker = "new_script_summary";
int oldStart = text.indexOf(oldSummaryMarker);
int newStart = text.indexOf(newSummaryMarker);
String oldSum = "";
String newSum = "";
if (oldStart != -1 && newStart != -1) {
oldSum = text.substring(oldStart + oldSummaryMarker.length(), newStart).trim();
newSum = text.substring(newStart + newSummaryMarker.length()).trim();
// Clear the doc if valid
clearGoogleDoc(docId);
log.info("Cleared content of Google Doc: {}", docId);
} else {
// Fallback if markers missing
log.warn("Markers not found in doc. NOT clearing doc.");
oldSum = "Markers not found in doc.";
newSum = text.substring(0, Math.min(text.length(), 200)) + "...";
}
if (feedback != null && !feedback.isEmpty()) {
newSum += "\n\n[Reflecting Feedback: " + feedback + "]";
}
return new com.hlab.yanalyst.domain.opal.dto.OpalDraftResponseDto(oldSum, newSum);
} catch (Exception e) {
log.error("Error fetching Opal draft from GDoc", e);
throw new RuntimeException("Error fetching Opal draft", e);
}
}
@Override
public String fetchFinalScript() {
// String docId = "1tThnN2-OdYS-RuAWUWFsW9W238TPqZssaww5_wULOg4";
String docId = "1jiSEwFuWeggIFln08j15pXw-8iUsFaDVjVKl3RbVdsw";
log.info("Fetching final script using Google Docs API from Doc ID: {}", docId);
try {
String text = readGoogleDoc(docId);
// Clear content
clearGoogleDoc(docId);
log.info("Cleared content of Google Doc: {}", docId);
return text;
} catch (Exception e) {
log.error("Error fetching final script", e);
throw new RuntimeException("Error fetching final script", e);
}
}
@Override
public String fetchOpeningScript(String docId) {
log.info("Fetching opening script using Google Docs API from Doc ID: {}", docId);
try {
String text = readGoogleDoc(docId);
// Clear content
clearGoogleDoc(docId);
log.info("Cleared content of Google Doc: {}", docId);
return text;
} catch (Exception e) {
log.error("Error fetching opening script", e);
throw new RuntimeException("Error fetching opening script", e);
}
}
// --- Google Docs Helper Methods ---
private static final String CREDENTIALS_FILE_PATH = "/credentials.json";
private static final java.util.List<String> SCOPES = Collections.singletonList(DocsScopes.DOCUMENTS);
private static final String TOKENS_DIRECTORY_PATH = "tokens";
private Docs getDocsService() throws Exception {
final NetHttpTransport HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport();
GsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
InputStream in = ExternalApiServiceImpl.class.getResourceAsStream(CREDENTIALS_FILE_PATH);
if (in == null) {
throw new FileNotFoundException("Resource not found: " + CREDENTIALS_FILE_PATH);
}
GoogleClientSecrets clientSecrets = GoogleClientSecrets.load(JSON_FACTORY, new InputStreamReader(in));
// Build flow and trigger user authorization request.
GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT, JSON_FACTORY, clientSecrets, SCOPES)
.setDataStoreFactory(new FileDataStoreFactory(new java.io.File(TOKENS_DIRECTORY_PATH)))
.setAccessType("offline")
.build();
LocalServerReceiver receiver = new LocalServerReceiver.Builder().setPort(8888).build();
// This authorize call will open browser if token is missing
Credential credential = new AuthorizationCodeInstalledApp(flow, receiver).authorize("user");
return new Docs.Builder(HTTP_TRANSPORT, JSON_FACTORY, credential)
.setApplicationName("HLAB-Backend")
.build();
}
private String readGoogleDoc(String docId) throws Exception {
Docs service = getDocsService();
Document doc = service.documents().get(docId).execute();
StringBuilder sb = new StringBuilder();
doc.getBody().getContent().forEach(c -> {
if (c.getParagraph() != null) {
c.getParagraph().getElements().forEach(e -> {
if (e.getTextRun() != null) {
sb.append(e.getTextRun().getContent());
}
});
sb.append("\n"); // Add line break for paragraphs
}
});
return sb.toString();
}
private void clearGoogleDoc(String docId) throws Exception {
Docs service = getDocsService();
Document doc = service.documents().get(docId).execute();
// Calculate the end index. Content usually ends with a newline.
int lastContentIndex = doc.getBody().getContent().size() - 1;
int docEnd = doc.getBody().getContent().get(lastContentIndex).getEndIndex();
log.info("Attempting to clear doc {}. Last content index: {}, Doc End Index: {}", docId, lastContentIndex, docEnd);
// Safety check: if doc is already practically empty
if (docEnd > 2) {
int deleteEndIndex = docEnd - 1;
log.info("Deleting range: 1 to {}", deleteEndIndex);
Request request = new Request()
.setDeleteContentRange(new DeleteContentRangeRequest()
.setRange(new Range().setStartIndex(1).setEndIndex(deleteEndIndex)));
BatchUpdateDocumentRequest body = new BatchUpdateDocumentRequest().setRequests(Collections.singletonList(request));
BatchUpdateDocumentResponse response = service.documents().batchUpdate(docId, body).execute();
log.info("Clear doc response: {}", response);
} else {
log.info("Doc appears empty or too small to clear (endIndex <= 2). Skipping delete.");
}
}
@Override
public Map<String, Object> generateFinalAsset(String finalScript) {
// Stub for now
try { Thread.sleep(2000); } catch (InterruptedException e) {}
Map<String, Object> asset = new HashMap<>();
asset.put("title", "Generated Title (Real)");
asset.put("summary", "Generated Summary (Real)");
asset.put("timeline", List.of(Map.of("time", "00:00", "desc", "Intro"), Map.of("time", "01:00", "desc", "Main Content")));
asset.put("video_prompt", "Cinematic prompt for " + finalScript.substring(0, 10));
asset.put("image_urls", List.of("https://via.placeholder.com/150", "https://via.placeholder.com/150"));
return asset;
}
}

View File

@ -0,0 +1,51 @@
package com.hlab.yanalyst.service.external;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.List;
@Service
public class ExternalApiServiceStub implements ExternalApiService {
@Override
public String generateScript(String videoUrl) {
// Stub: simulate python script generation
try { Thread.sleep(1000); } catch (InterruptedException e) {}
return "Stub Script Content for " + videoUrl + "\n\n[Intro]\nHello everyone, today we are analyzing...";
}
@Override
public com.hlab.yanalyst.domain.opal.dto.OpalDraftResponseDto generateOpalDraft(String script, String feedback, String mode) {
// Stub: simulate opal generation
try { Thread.sleep(1500); } catch (InterruptedException e) {}
String prefix = feedback != null ? "[Reflecting Feedback: " + feedback + "]\n" : "";
return new com.hlab.yanalyst.domain.opal.dto.OpalDraftResponseDto(
"Stub Old Summary from " + script.substring(0, Math.min(script.length(), 20)),
prefix + "Stub New Summary: " + script.substring(0, Math.min(script.length(), 20))
);
}
@Override
public String fetchFinalScript() {
return "Stub Final Script Content";
}
@Override
public String fetchOpeningScript(String docId) {
return "Stub Opening Script Content from " + docId;
}
@Override
public Map<String, Object> generateFinalAsset(String finalScript) {
// Stub: simulate final asset generation
try { Thread.sleep(2000); } catch (InterruptedException e) {}
Map<String, Object> asset = new HashMap<>();
asset.put("title", "Generated Title");
asset.put("summary", "Generated Summary");
asset.put("timeline", List.of(Map.of("time", "00:00", "desc", "Intro"), Map.of("time", "01:00", "desc", "Main Topic")));
asset.put("video_prompt", "A cinematic shot of...");
asset.put("image_urls", List.of("http://example.com/img1.jpg", "http://example.com/img2.jpg"));
return asset;
}
}

View File

@ -0,0 +1,37 @@
package com.hlab.yanalyst.web;
import com.hlab.yanalyst.domain.channel.Channel;
import com.hlab.yanalyst.domain.channel.ChannelService;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/channels")
@RequiredArgsConstructor
@Tag(name = "Channel API", description = "YouTube Channel Management API")
public class ChannelApiController {
private final ChannelService channelService;
@PostMapping
@Operation(summary = "Add Channel", description = "Add a YouTube channel by its URL. Fetches details from YouTube API.")
public ApiResponse<Channel> addChannel(@RequestBody Map<String, String> payload) {
String url = payload.get("url");
if (url == null || url.isEmpty()) {
throw new IllegalArgumentException("URL is required");
}
return ApiResponse.ok(channelService.saveChannelFromUrl(url));
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete Channel", description = "Delete a YouTube channel and all its data")
public ApiResponse<Void> deleteChannel(@PathVariable Long id) {
channelService.deleteChannel(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,64 @@
package com.hlab.yanalyst.web;
import com.hlab.yanalyst.domain.channel.Channel;
import com.hlab.yanalyst.domain.channel.ChannelVideo;
import com.hlab.yanalyst.domain.channel.ChannelService;
import com.hlab.yanalyst.global.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
@RequiredArgsConstructor
@Tag(name = "Channel Detail", description = "Channel Detail & Video Management")
public class ChannelDetailController {
private final ChannelService channelService;
@GetMapping("/channels/{id}")
public String channelDetail(@PathVariable("id") Long id, Model model) {
Channel channel = channelService.getChannel(id);
model.addAttribute("channel", channel);
model.addAttribute("videos", channelService.getChannelVideos(id));
return "channel_detail";
}
@GetMapping("/channels/videos")
public String channelsVideos(@RequestParam("ids") java.util.List<Long> ids, Model model) {
java.util.List<Channel> channels = channelService.getChannelsByIds(ids);
java.util.List<String> selectedYoutubeIds = channels.stream()
.map(Channel::getChannelId)
.toList();
model.addAttribute("channels", channels);
model.addAttribute("selectedYoutubeIds", selectedYoutubeIds);
return "multi_channel_videos";
}
@PostMapping("/api/channels/{id}/sync")
@ResponseBody
@Operation(summary = "Sync Channel Videos", description = "Fetch and sync all videos from the channel's upload playlist.")
public ApiResponse<String> syncChannelVideos(@PathVariable("id") Long id) {
channelService.collectChannelVideos(id);
return ApiResponse.ok("Synced successfully");
}
@PostMapping("/api/channels/videos/{videoId}/script")
@ResponseBody
@Operation(summary = "Extract Script", description = "Extract transcript for a specific video.")
public ApiResponse<Void> extractScript(@PathVariable("videoId") Long videoId) {
channelService.extractScript(videoId);
return ApiResponse.ok(null);
}
@PostMapping("/api/channels/{channelId}/scripts/all")
@ResponseBody
@Operation(summary = "Extract All Scripts", description = "Extract transcripts for all videos in the channel.")
public ApiResponse<String> extractAllScripts(@PathVariable("channelId") Long channelId) {
channelService.extractAllScripts(channelId);
return ApiResponse.ok("Bulk extraction completed");
}
}

View File

@ -0,0 +1,81 @@
package com.hlab.yanalyst.web;
import com.hlab.yanalyst.domain.opal.OpalDraft;
import com.hlab.yanalyst.domain.opal.OpalFinal;
import com.hlab.yanalyst.domain.opal.OpalFinalAsset;
import com.hlab.yanalyst.domain.script.ScriptGen;
import com.hlab.yanalyst.global.common.ApiResponse;
import com.hlab.yanalyst.service.AnalysisWorkflowService;
import com.hlab.yanalyst.web.dto.DraftGenerateRequest;
import com.hlab.yanalyst.web.dto.FinalAssetResponse;
import com.hlab.yanalyst.web.dto.OpalDraftResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/videos")
@RequiredArgsConstructor
public class VideoActionController {
private final AnalysisWorkflowService workflowService;
@PostMapping("/{videoId}/script")
public ApiResponse<Void> generateScript(@PathVariable Long videoId) {
workflowService.generateScript(videoId);
return ApiResponse.ok(null);
}
@PostMapping("/{videoId}/drafts")
public ApiResponse<OpalDraftResponse> generateDraft(
@PathVariable Long videoId,
@RequestBody(required = false) DraftGenerateRequest request) {
String feedback = request != null ? request.getFeedback() : null;
String mode = request != null ? request.getMode() : null;
OpalDraft draft = workflowService.generateDraft(videoId, feedback, mode);
return ApiResponse.created(new OpalDraftResponse(draft));
}
@PostMapping("/{videoId}/drafts/{draftId}/accept")
public ApiResponse<Void> acceptDraft(
@PathVariable Long videoId,
@PathVariable Long draftId) {
workflowService.acceptDraft(videoId, draftId);
return ApiResponse.ok(null);
}
@PostMapping("/{videoId}/final-asset")
public ApiResponse<FinalAssetResponse> generateFinalAsset(@PathVariable Long videoId) {
OpalFinalAsset asset = workflowService.generateFinalAsset(videoId);
// Need to refetch opalFinal to pass to DTO, strictly strictly speaking asset has reference
return ApiResponse.created(new FinalAssetResponse(asset.getOpalFinal(), asset));
}
@PostMapping("/{videoId}/complete")
public ApiResponse<Boolean> toggleComplete(@PathVariable Long videoId) {
boolean isCompleted = workflowService.toggleComplete(videoId);
return ApiResponse.ok(isCompleted);
}
@PutMapping("/{videoId}/final-asset")
public ApiResponse<Void> updateFinalAsset(
@PathVariable Long videoId,
@RequestBody java.util.Map<String, String> body) {
String newText = body.get("finalScriptText");
workflowService.updateFinalAsset(videoId, newText);
return ApiResponse.ok(null);
}
@PostMapping("/{videoId}/opening")
public ApiResponse<Void> generateOpening(
@PathVariable Long videoId,
@RequestBody java.util.Map<String, String> body) {
String docId = body.get("docId");
workflowService.generateOpening(videoId, docId);
return ApiResponse.ok(null);
}
@DeleteMapping("/{videoId}/opening")
public ApiResponse<Void> resetOpening(@PathVariable Long videoId) {
workflowService.resetOpening(videoId);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,89 @@
package com.hlab.yanalyst.web;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class WebController {
private final com.hlab.yanalyst.service.YtVideoService ytVideoService;
private final com.hlab.yanalyst.domain.production.ProductionService productionService;
private final com.hlab.yanalyst.domain.channel.ChannelService channelService;
public WebController(com.hlab.yanalyst.service.YtVideoService ytVideoService, com.hlab.yanalyst.domain.production.ProductionService productionService, com.hlab.yanalyst.domain.channel.ChannelService channelService) {
this.ytVideoService = ytVideoService;
this.productionService = productionService;
this.channelService = channelService;
}
@GetMapping("/")
public String dashboard(Model model) {
model.addAttribute("currentPage", "dashboard");
return "dashboard";
}
@GetMapping("/channels")
public String channels(Model model) {
model.addAttribute("currentPage", "channels");
model.addAttribute("channels", channelService.getAllChannels());
return "channels";
}
@GetMapping("/videos")
public String videos(Model model) {
model.addAttribute("currentPage", "videos");
return "videos";
}
@GetMapping("/videos/{videoId}")
public String videoDetail(@org.springframework.web.bind.annotation.PathVariable Long videoId, Model model) {
model.addAttribute("currentPage", "videos");
model.addAttribute("video", ytVideoService.getVideoDetail(videoId));
// Also fetch drafts and final asset initially to render server-side if needed,
// or let the view fetch via AJAX.
// For 'Draft History' and 'Final', let's pass them to model for initial load to avoid layout shift.
model.addAttribute("drafts", ytVideoService.getDrafts(videoId));
model.addAttribute("finalAsset", ytVideoService.getFinalAsset(videoId));
return "video_detail";
}
@GetMapping("/collection")
public String collection(Model model) {
model.addAttribute("currentPage", "collection");
return "collection";
}
@GetMapping("/board")
public String board(Model model) {
model.addAttribute("currentPage", "board");
return "board";
}
@GetMapping("/publish")
public String publish(Model model) {
model.addAttribute("currentPage", "publish");
return "publish";
}
@GetMapping("/rework/{id}")
public String rework(@org.springframework.web.bind.annotation.PathVariable Long id, Model model) {
model.addAttribute("currentPage", "collection");
model.addAttribute("videoId", id);
return "rework";
}
@GetMapping("/production")
public String production(Model model) {
model.addAttribute("currentPage", "production");
model.addAttribute("historyList", productionService.getAllHistory());
return "production";
}
@GetMapping("/production/{id}")
public String productionDetail(@org.springframework.web.bind.annotation.PathVariable Long id, Model model) {
model.addAttribute("currentPage", "production");
model.addAttribute("history", productionService.getHistory(id));
return "production_detail";
}
}

View File

@ -0,0 +1,36 @@
package com.hlab.yanalyst.web;
import com.hlab.yanalyst.domain.channel.SearchCollectionService;
import com.hlab.yanalyst.global.common.ApiResponse;
import com.hlab.yanalyst.service.YtVideoService;
import com.hlab.yanalyst.web.dto.YoutubeSearchCondition;
import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/youtube")
@RequiredArgsConstructor
public class YoutubeSearchApiController {
private final YtVideoService ytVideoService;
private final SearchCollectionService searchCollectionService;
@PostMapping("/search")
public com.hlab.yanalyst.web.dto.YoutubeSearchPageDto search(@RequestBody YoutubeSearchCondition condition) {
return ytVideoService.searchYoutubeVideos(condition);
}
@PostMapping("/collect")
@Operation(summary = "검색 결과 일괄 수집",
description = "검색 결과(YoutubeSearchResultDto 목록)를 수집함에 저장한다. 이미 수집된 영상은 자동으로 건너뛴다. body: 검색 결과 객체 배열")
public ApiResponse<SearchCollectionService.CollectResult> collect(@RequestBody List<YoutubeSearchResultDto> items) {
return ApiResponse.ok(searchCollectionService.collectFromSearch(items));
}
}

View File

@ -0,0 +1,62 @@
package com.hlab.yanalyst.web;
import com.hlab.yanalyst.global.common.ApiResponse;
import com.hlab.yanalyst.service.YtVideoService;
import com.hlab.yanalyst.web.dto.FinalAssetResponse;
import com.hlab.yanalyst.web.dto.OpalDraftResponse;
import com.hlab.yanalyst.web.dto.VideoDetailResponse;
import com.hlab.yanalyst.web.dto.VideoResponse;
import com.hlab.yanalyst.web.dto.VideoSearchCondition;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/videos")
@RequiredArgsConstructor
public class YtVideoController {
private final YtVideoService ytVideoService;
@org.springframework.web.bind.annotation.PostMapping
public ApiResponse<?> addVideo(@org.springframework.web.bind.annotation.RequestBody com.hlab.yanalyst.web.dto.VideoAddRequest request) {
try {
ytVideoService.saveVideoFromUrl(request.getUrl());
return ApiResponse.ok("Video added successfully");
} catch (IllegalArgumentException e) {
return ApiResponse.error(e.getMessage());
} catch (Exception e) {
return ApiResponse.error("Failed to add video: " + e.getMessage());
}
}
@GetMapping
public ApiResponse<Page<VideoResponse>> getVideos(
VideoSearchCondition condition,
@PageableDefault(size = 20, sort = "lastCrawledAt", direction = Sort.Direction.DESC) Pageable pageable) {
return ApiResponse.ok(ytVideoService.getVideos(condition, pageable));
}
@GetMapping("/{videoId}")
public ApiResponse<VideoDetailResponse> getVideoDetail(@PathVariable Long videoId) {
return ApiResponse.ok(ytVideoService.getVideoDetail(videoId));
}
@GetMapping("/{videoId}/drafts")
public ApiResponse<List<OpalDraftResponse>> getDrafts(@PathVariable Long videoId) {
return ApiResponse.ok(ytVideoService.getDrafts(videoId));
}
@GetMapping("/{videoId}/final-asset")
public ApiResponse<FinalAssetResponse> getFinalAsset(@PathVariable Long videoId) {
return ApiResponse.ok(ytVideoService.getFinalAsset(videoId));
}
}

View File

@ -0,0 +1,9 @@
package com.hlab.yanalyst.web.dto;
import lombok.Data;
@Data
public class DraftGenerateRequest {
private String feedback;
private String mode; // "TRUE_STORY" or "STRUCTURE_CHANGE"
}

View File

@ -0,0 +1,22 @@
package com.hlab.yanalyst.web.dto;
import com.hlab.yanalyst.domain.opal.OpalFinal;
import com.hlab.yanalyst.domain.opal.OpalFinalAsset;
import lombok.Data;
@Data
public class FinalAssetResponse {
private Long finalId;
private String finalScriptText;
private String assetJson;
private Boolean isActive;
public FinalAssetResponse(OpalFinal opalFinal, OpalFinalAsset asset) {
this.finalId = opalFinal.getFinalId();
this.finalScriptText = opalFinal.getFinalScriptText();
this.isActive = opalFinal.getIsActive();
if (asset != null) {
this.assetJson = asset.getAssetJson();
}
}
}

View File

@ -0,0 +1,30 @@
package com.hlab.yanalyst.web.dto;
import com.hlab.yanalyst.domain.opal.OpalDraft;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class OpalDraftResponse {
private Long draftId;
private Integer versionNo;
private String responseText;
private String userFeedback;
private Boolean isAccepted;
private LocalDateTime createdAt;
private String oldScriptSummary;
private String newScriptSummary;
public OpalDraftResponse(OpalDraft draft) {
this.draftId = draft.getDraftId();
this.versionNo = draft.getVersionNo();
this.responseText = draft.getResponseText();
this.userFeedback = draft.getUserFeedback();
this.isAccepted = draft.getIsAccepted();
this.createdAt = draft.getCreatedAt();
this.oldScriptSummary = draft.getOldScriptSummary();
this.newScriptSummary = draft.getNewScriptSummary();
}
}

View File

@ -0,0 +1,8 @@
package com.hlab.yanalyst.web.dto;
import lombok.Data;
@Data
public class VideoAddRequest {
private String url;
}

View File

@ -0,0 +1,36 @@
package com.hlab.yanalyst.web.dto;
import com.hlab.yanalyst.domain.video.YtVideo;
import lombok.Data;
@Data
public class VideoDetailResponse {
private VideoResponse metadata;
private String videoUrl;
private String youtubeVideoId;
// We will load Script, Draft, Final via separate API or include them here?
// Requirement says "Center Area: 3 Tabs".
// It's better to fetch detail data including status, but maybe load heavy text data lazy or in this response.
// Given the requirement "Show script_gen.response_text", it might be large.
// Let's include everything for simplicity as per common SPA patterns unless it's huge.
private String scriptContent; // ScriptGen.responseText
private String scriptStatus;
private String openingScript;
// We can return list of drafts separately or here.
// Let's create a separate API for drafts history to keep this clean,
// or just include basic info.
// Requirement: "Opal Draft History - list newest first".
// Let's fetch the detailed lists via separate calls to keep payload light and responsive.
// But for "Script" tab, if it's 1:1, we can include it.
public VideoDetailResponse(YtVideo video) {
this.metadata = new VideoResponse(video);
this.videoUrl = video.getVideoUrl();
this.youtubeVideoId = video.getYoutubeVideoId();
this.openingScript = video.getOpeningScript();
}
}

View File

@ -0,0 +1,36 @@
package com.hlab.yanalyst.web.dto;
import com.hlab.yanalyst.domain.video.YtVideo;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class VideoResponse {
private Long videoId;
private String thumbnailUrl;
private String title;
private String channelTitle;
private Long viewCount;
private Long subscriberCount;
private BigDecimal viewsPerSubRatio;
private BigDecimal viewsPerHour;
private String status;
private LocalDateTime lastCrawledAt;
private Boolean isCompleted;
public VideoResponse(YtVideo video) {
this.videoId = video.getVideoId();
this.thumbnailUrl = video.getThumbnailUrl();
this.title = video.getTitle();
this.channelTitle = video.getChannelTitle();
this.viewCount = video.getViewCount();
this.subscriberCount = video.getSubscriberCount();
this.viewsPerSubRatio = video.getViewsPerSubRatio();
this.viewsPerHour = video.getViewsPerHour();
this.status = video.getStatus();
this.lastCrawledAt = video.getLastCrawledAt();
this.isCompleted = video.getIsCompleted() != null && video.getIsCompleted();
}
}

View File

@ -0,0 +1,18 @@
package com.hlab.yanalyst.web.dto;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
@Data
public class VideoSearchCondition {
private String status;
private String keyword; // title or channelTitle
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate startDate;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate endDate;
}

View File

@ -0,0 +1,14 @@
package com.hlab.yanalyst.web.dto;
import lombok.Data;
import java.util.List;
@Data
public class YoutubeSearchCondition {
private String keyword;
private List<String> regions; // e.g. ["JP", "US"]
private Integer periodDays; // e.g. 1, 7, 15, 30
private String format; // "SHORTS" or "LONG_FORM"
private java.util.Map<String, String> pageTokens;
private List<String> channelIds; // e.g. ["UC...", "UC..."]
}

View File

@ -0,0 +1,11 @@
package com.hlab.yanalyst.web.dto;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class YoutubeSearchPageDto {
private List<YoutubeSearchResultDto> items;
private Map<String, String> nextTokens;
}

View File

@ -0,0 +1,19 @@
package com.hlab.yanalyst.web.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class YoutubeSearchResultDto {
private String videoId;
private String title;
private String thumbnailUrl;
private LocalDateTime publishedAt;
private Long viewCount;
private String channelId;
private String channelTitle;
private Long subscriberCount;
private String channelCountry;
private Integer durationSec;
private java.util.List<String> hashtags = new java.util.ArrayList<>();
}

View File

@ -0,0 +1,55 @@
spring:
application:
name: h-lab
datasource:
url: ${DB_URL:jdbc:postgresql://122.46.74.90:5432/hlab}
driverClassName: org.postgresql.Driver
username: ${DB_USERNAME:hylee}
password: ${DB_PASSWORD:hylee123!@#}
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
format_sql: false
decorator:
datasource:
p6spy:
enable-logging: true
multiline: true
logging: slf4j
log-message-format: com.hlab.yanalyst.global.config.P6SpyFormatter
tracing:
include-parameter-values: true
springdoc:
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
api-docs:
path: /v3/api-docs
server:
port: 8088
# YouTube Data API 키 (환경변수 YOUTUBE_API_KEY 로 오버라이드 가능, 기본값은 기존 키)
youtube:
api:
key: ${YOUTUBE_API_KEY:AIzaSyB1oh0pahAf0xl0DMRQnuqxrC1uapxHHKk}
hlab:
# 정기 자동 수집: 등록 채널의 신규 Shorts 를 주기적으로 수집
scheduler:
channel-collection:
enabled: ${CHANNEL_COLLECTION_ENABLED:true}
cron: ${CHANNEL_COLLECTION_CRON:0 0 4 * * *} # 매일 04:00
channel-snapshot:
enabled: ${CHANNEL_SNAPSHOT_ENABLED:true}
cron: ${CHANNEL_SNAPSHOT_CRON:0 0 3 * * *} # 매일 03:00 (성장 추이 기록)
youtube:
daily-quota: ${YOUTUBE_DAILY_QUOTA:10000} # 자동 수집이 소비할 수 있는 일일 쿼터 상한(추정)

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is the JRebel configuration file. It maps the running application to your IDE workspace, enabling JRebel reloading for this project.
Refer to https://manuals.jrebel.com/jrebel/standalone/config.html for more information.
-->
<application generated-by="intellij" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://www.zeroturnaround.com" xsi:schemaLocation="http://www.zeroturnaround.com http://update.zeroturnaround.com/jrebel/rebel-2_3.xsd">
<id>h-lab.main</id>
<classpath>
<dir name="D:/Development/InteliJ_repository/h-lab/build/resources/main">
</dir>
<dir name="D:/Development/InteliJ_repository/h-lab/build/classes/java/main">
</dir>
</classpath>
</application>

Some files were not shown because too many files have changed in this diff Show More