Baseline before video model consolidation
This commit is contained in:
commit
da04dbe15c
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
@ -0,0 +1 @@
|
||||
{"sessionId":"e9f3e0a1-1f0f-4bb8-ac46-7ffa8167e897","pid":110816,"procStart":"639156613729251550","acquiredAt":1780035816876}
|
||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
|
||||
6098
.playwright-mcp/page-2026-05-29T08-50-44-058Z.yml
Normal file
6098
.playwright-mcp/page-2026-05-29T08-50-44-058Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
71
.playwright-mcp/page-2026-05-29T08-56-10-071Z.yml
Normal file
71
.playwright-mcp/page-2026-05-29T08-56-10-071Z.yml
Normal 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]: 로딩 중...
|
||||
107
.playwright-mcp/page-2026-05-30T00-01-37-305Z.yml
Normal file
107
.playwright-mcp/page-2026-05-30T00-01-37-305Z.yml
Normal 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]
|
||||
46
.playwright-mcp/page-2026-05-30T03-47-52-241Z.yml
Normal file
46
.playwright-mcp/page-2026-05-30T03-47-52-241Z.yml
Normal 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: 새로고침
|
||||
3094
.playwright-mcp/page-2026-05-30T07-27-31-828Z.yml
Normal file
3094
.playwright-mcp/page-2026-05-30T07-27-31-828Z.yml
Normal file
File diff suppressed because it is too large
Load Diff
72
CLAUDE.md
Normal file
72
CLAUDE.md
Normal 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
50
README.md
Normal 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
51
build.gradle
Normal 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
BIN
build_log.txt
Normal file
Binary file not shown.
BIN
build_log_final.txt
Normal file
BIN
build_log_final.txt
Normal file
Binary file not shown.
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
251
gradlew
vendored
Normal 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
94
gradlew.bat
vendored
Normal 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
1
settings.gradle
Normal file
@ -0,0 +1 @@
|
||||
rootProject.name = 'h-lab'
|
||||
17
src/main/java/com/hlab/yanalyst/HLabApplication.java
Normal file
17
src/main/java/com/hlab/yanalyst/HLabApplication.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
82
src/main/java/com/hlab/yanalyst/domain/channel/Channel.java
Normal file
82
src/main/java/com/hlab/yanalyst/domain/channel/Channel.java
Normal 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
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
213
src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java
Normal file
213
src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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")));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
65
src/main/java/com/hlab/yanalyst/domain/opal/OpalDraft.java
Normal file
65
src/main/java/com/hlab/yanalyst/domain/opal/OpalDraft.java
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
45
src/main/java/com/hlab/yanalyst/domain/opal/OpalFinal.java
Normal file
45
src/main/java/com/hlab/yanalyst/domain/opal/OpalFinal.java
Normal 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";
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package com.hlab.yanalyst.domain.production;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ProductionCrawlHistoryRepository extends JpaRepository<ProductionCrawlHistory, Long> {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package com.hlab.yanalyst.domain.production;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ProductionVideoRepository extends JpaRepository<ProductionVideo, Long> {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
58
src/main/java/com/hlab/yanalyst/domain/script/ScriptGen.java
Normal file
58
src/main/java/com/hlab/yanalyst/domain/script/ScriptGen.java
Normal 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;
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package com.hlab.yanalyst.domain.script;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ScriptGenRepository extends JpaRepository<ScriptGen, Long> {
|
||||
}
|
||||
64
src/main/java/com/hlab/yanalyst/domain/video/Video.java
Normal file
64
src/main/java/com/hlab/yanalyst/domain/video/Video.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
84
src/main/java/com/hlab/yanalyst/domain/video/YtVideo.java
Normal file
84
src/main/java/com/hlab/yanalyst/domain/video/YtVideo.java
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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/");
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
487
src/main/java/com/hlab/yanalyst/service/YtVideoService.java
Normal file
487
src/main/java/com/hlab/yanalyst/service/YtVideoService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
14
src/main/java/com/hlab/yanalyst/service/external/ExternalApiService.java
vendored
Normal file
14
src/main/java/com/hlab/yanalyst/service/external/ExternalApiService.java
vendored
Normal 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);
|
||||
}
|
||||
262
src/main/java/com/hlab/yanalyst/service/external/ExternalApiServiceImpl.java
vendored
Normal file
262
src/main/java/com/hlab/yanalyst/service/external/ExternalApiServiceImpl.java
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
51
src/main/java/com/hlab/yanalyst/service/external/ExternalApiServiceStub.java
vendored
Normal file
51
src/main/java/com/hlab/yanalyst/service/external/ExternalApiServiceStub.java
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
89
src/main/java/com/hlab/yanalyst/web/WebController.java
Normal file
89
src/main/java/com/hlab/yanalyst/web/WebController.java
Normal 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";
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
62
src/main/java/com/hlab/yanalyst/web/YtVideoController.java
Normal file
62
src/main/java/com/hlab/yanalyst/web/YtVideoController.java
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package com.hlab.yanalyst.web.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class VideoAddRequest {
|
||||
private String url;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
36
src/main/java/com/hlab/yanalyst/web/dto/VideoResponse.java
Normal file
36
src/main/java/com/hlab/yanalyst/web/dto/VideoResponse.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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..."]
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<>();
|
||||
}
|
||||
55
src/main/resources/application.yml
Normal file
55
src/main/resources/application.yml
Normal 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} # 자동 수집이 소비할 수 있는 일일 쿼터 상한(추정)
|
||||
18
src/main/resources/rebel.xml
Normal file
18
src/main/resources/rebel.xml
Normal 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
Loading…
Reference in New Issue
Block a user