Compare commits
No commits in common. "master" and "opal" have entirely different histories.
@ -1,65 +1,26 @@
|
||||
---
|
||||
name: shortform-queue
|
||||
description: h-lab 숏폼 큐의 대기 작업을 구글 Opal "숏폼 생성기"로 실행하고, 결과를 DB에 저장한 뒤 캡컷 에이전트(127.0.0.1:8001)로 영상 생성까지 넘긴다. "숏폼 돌려줘", "숏폼 큐 돌려줘", "/shortform-queue", "숏폼 1단계", "풀버전으로 돌려줘" 요청 시 사용.
|
||||
description: h-lab 숏폼 큐의 대기 작업을 구글 Opal "숏폼 생성기"로 실행하고 결과를 DB에 저장. "숏폼 큐 돌려줘", "/shortform-queue", "숏폼 돌려줘" 요청 시 사용.
|
||||
---
|
||||
|
||||
# 숏폼 큐 실행 (Opal 브라우저 대행)
|
||||
|
||||
h-lab(localhost:8088)의 PENDING 작업을 Opal로 실행해 결과를 저장한다.
|
||||
h-lab 서버가 꺼져 있으면 먼저 `.\gradlew.bat bootRun`(JAVA_HOME=D:\Development\app\JDK\jdk-21.0.5)으로 띄운다.
|
||||
서버 시크릿은 git-ignored `application-local.yml` — 이 워크트리에 없으면 메인 워크트리(`D:\Development\InteliJ_repository\h-lab\src\main\resources\`)에서 복사.
|
||||
|
||||
## 모드 선택
|
||||
|
||||
| 요청 | 앱 | URL | 출력 형식 |
|
||||
|------|-----|-----|----------|
|
||||
| **"숏폼 돌려줘", "숏폼 큐 돌려줘" (기본)** | 숏폼 생성기 1단계 | `https://opal.google/edit/1Z9zXUiBAjKKOJ08zXxAmE9Neliq86SEw` | `{"candidates":[...]}` JSON 하나 |
|
||||
| "풀버전으로 돌려줘", "전체 돌려줘" | 숏폼 생성기 (풀버전) | `https://opal.google/edit/1w3mThvSpI3_skt4p4OAN3-M6-tnqz0HN` | `N=====` 구간 5개 + cuts JSON |
|
||||
|
||||
사용자가 Opal URL을 직접 주면 그 URL 우선. 파서(ShortformOutputParser)는 두 형식 모두 인식한다(88179bb) — 어느 모드든 저장하면 DONE + 클립 5개.
|
||||
|
||||
## 절차 (공통)
|
||||
## 절차
|
||||
|
||||
1. **큐 조회**: `GET http://localhost:8088/api/shortform/jobs?status=PENDING` → 비어있으면 "대기 작업 없음" 보고 후 종료.
|
||||
2. **Opal 접속**: 크롬 자동화로 모드에 맞는 편집 화면 접속. 로그인 풀려 있으면 사용자에게 로그인 요청.
|
||||
2. **Opal 접속**: 크롬 자동화로 `https://opal.google/edit/1w3mThvSpI3_skt4p4OAN3-M6-tnqz0HN` (숏폼 생성기 편집 화면). 로그인 풀려 있으면 사용자에게 로그인 요청.
|
||||
3. 각 PENDING 작업마다:
|
||||
a. 캔버스의 **YouTube Video 노드 클릭** → 우측 패널 URL 입력창을 비우고(전체선택 ctrl+a 후 덮어쓰기) 작업의 youtubeUrl 입력 → **Apply** (썸네일이 해당 영상으로 바뀌는지 확인).
|
||||
a. 캔버스의 **YouTube Video 노드 클릭** → 우측 패널 URL 입력창을 비우고 작업의 youtubeUrl 입력 → **Apply**.
|
||||
b. Preview 패널의 새로고침(리셋) 아이콘 → **Start** 클릭.
|
||||
c. **완료 폴링** 및 **추출**: 모드별 절차(아래) 참조.
|
||||
d. **JSON 검증·보정** (1단계 모드에서 저장 전 필수): 추출 텍스트를 `ConvertFrom-Json`으로 파싱해본다. 실패하면 스킬 폴더의 `fix-opal-json.ps1`로 보정하고 다시 검증한다 — 통과하면 보정본으로 저장·전달하고, 여전히 깨지면 저장하지 말고(PENDING 유지) 원문과 파싱 오류를 보고한다. 보정 후 candidates가 5개인지도 확인. (풀버전은 `N=====` 구간 5개라 통짜 파싱 대상이 아님 — 저장 후 응답 clips 개수로 확인한다.)
|
||||
e. **저장**: 브라우저 fetch는 HTTPS→HTTP라 차단됨 — 추출 텍스트를 파일로 옮겨 셸에서 `POST http://localhost:8088/api/shortform/jobs/{id}/result` body `{"rawText": "<Output 원문>", "step1Text": "<Step 1 원문>"}`. PowerShell은 UTF-8 바이트로 전송(`[Text.Encoding]::UTF8.GetBytes`), 응답 한글이 깨져 보이는 건 PS 5.1 표시 문제이므로 검증은 curl로. 응답의 clips가 5개 미만이면 원문과 함께 보고.
|
||||
f. **capcut 에이전트 넘기기**: 저장이 DONE이면 아래 "capcut 에이전트 연동" 절차로 영상 생성을 **시작만** 하고, 완료를 기다리지 않고 즉시 다음 작업의 Opal 실행으로 넘어간다.
|
||||
c. **완료 폴링**: 40~60초 간격 스크린샷. "Thinking... Step N" 진행 표시가 사라지고 Output(1===== 형식)이 렌더링되면 완료. 전체 4~5분 소요(Step 3 다섯 개가 각 ~90초 병렬).
|
||||
d. **Output 추출**: 앱 UI는 접근성 트리에 안 잡힌다. javascript_tool로 `document.getElementById('opal-app').contentDocument`에서 shadow DOM을 재귀 관통해 `"cuts"`를 포함한 **가장 긴** innerText를 찾는다. 텍스트가 크면 window 변수에 저장 후 900자 단위로 나눠 회수한다.
|
||||
d-2. **Step 1 결과 추출**: Console 탭에서 "Step 1: 하이라이트 후보 구간 추출" 항목을 UI 클릭으로 펼친 뒤(lazy 렌더라 JS로 details.open만 하면 내용이 안 붙을 수 있음 — 안 되면 화면 좌표 클릭), shadow DOM 관통으로 `"candidates"`를 포함한 **가장 짧은** innerText(Model Response 부분)를 회수한다.
|
||||
e. **저장**: `POST http://localhost:8088/api/shortform/jobs/{id}/result` body `{"rawText": "<Output 원문>", "step1Text": "<Step 1 원문>"}`. step1Text는 추출 실패 시 생략 가능(비파괴). 응답의 clips가 5개 미만이면 원문과 함께 보고.
|
||||
4. **에러 처리**: 특정 Step 노드가 빨간 표시로 실패하면 Preview 리셋 → Start로 1회 재실행. 재실패 시 해당 작업은 건너뛰고 사유를 보고(저장하지 않음 — PENDING 유지). 프롬프트 노드 끝에 중복 YouTube Video 칩이 생겼는지 확인(2026-08-03에 이 원인으로 ID 3이 항상 실패했음).
|
||||
5. **보고**: 처리한 작업 수, 성공/실패 목록, capcut으로 넘긴 작업과 진행 중인 탭 수, h-lab `/shortform` 링크로 요약.
|
||||
|
||||
## 풀버전 모드
|
||||
|
||||
- **완료 폴링**: 40~60초 간격 스크린샷. "Thinking... Step N" 진행 표시가 사라지고 Output(1===== 형식)이 렌더링되면 완료. 전체 4~5분 소요(Step 3 다섯 개가 각 ~90초 병렬).
|
||||
- **Output 추출**: 앱 UI는 접근성 트리에 안 잡힌다. javascript_tool로 `document.getElementById('opal-app').contentDocument`에서 shadow DOM을 재귀 관통해 `"cuts"`를 포함한 **가장 긴** innerText를 찾는다. 텍스트가 크면 window 변수에 저장 후 900자 단위로 나눠 회수한다.
|
||||
- **Step 1 결과 추출**: Console 탭에서 "Step 1: 하이라이트 후보 구간 추출" 항목을 UI 클릭으로 펼친 뒤(lazy 렌더라 JS로 details.open만 하면 내용이 안 붙을 수 있음 — 안 되면 화면 좌표 클릭), shadow DOM 관통으로 `"candidates"`를 포함한 **가장 짧은** innerText(Model Response 부분)를 회수한다.
|
||||
|
||||
## 1단계 모드 (기본)
|
||||
|
||||
- 노드 3개뿐: YouTube Video → Step 1(하이라이트 후보 5개) → Generate HTML. 전체 **~1분**이면 끝난다(10초 간격 폴링, "Thinking... Step 1: N%"가 사라지고 candidates JSON이 렌더링되면 완료).
|
||||
- **추출**: shadow DOM 관통으로 `"candidates"`를 포함한 **가장 긴** innerText 하나만 회수(~2KB, 통짜 회수 가능). Console 추출 불필요 — Output 자체가 Step 1 결과다.
|
||||
- **저장**: rawText와 step1Text에 **같은 텍스트**를 넣는다.
|
||||
|
||||
## capcut 에이전트 연동 (저장 직후 · 시작만)
|
||||
|
||||
작업 저장이 DONE 되면 그 작업의 영상 생성을 캡컷 에이전트에 바로 넘긴다. **완료를 기다리지 않는다** — 시작된 것만 확인하고 다음 작업의 Opal 실행으로 넘어간다. 여러 작업이 동시에 돌아도 서버는 `analysis_id`별로 독립 처리하므로(`server/app.py`의 `ANALYSES` 딕셔너리, 전역 락 없음) 충돌하지 않는다.
|
||||
|
||||
1. **생존 확인**: `GET http://127.0.0.1:8001` — 200이 아니면 이 단계 전체를 건너뛰고 "캡컷 에이전트 꺼져 있어 넘기지 않음"으로 보고한다(작업 상태는 DONE 유지).
|
||||
2. **작업마다 새 탭**을 연다(`http://127.0.0.1:8001`). 한 탭에서 두 번 실행하면 `analyze()`가 이전 실행의 화면 상태(`A`/`P`/`AUTO_AID`)를 초기화해 먼저 돌던 작업의 진행 화면이 깨진다.
|
||||
3. 탭에서 순서대로:
|
||||
- `#tab-auto` 클릭 (🤖 자동 탭)
|
||||
- 방식 선택 — 1단계 모드(기본)는 **📐 구간 JSON** `input[name="amode"][value="wpaste"]`, 풀버전은 **📋 오팔 JSON** `value="paste"`
|
||||
- `#autoUrl` ← 작업의 `youtubeUrl` (구간 JSON에는 URL이 없어 필수. 오팔 JSON 모드에서는 선택)
|
||||
- `#apaste` ← 저장에 쓴 원문 그대로 (1단계=candidates JSON, 풀버전=Output 통짜)
|
||||
- `#autoAutoRun`(끝까지 진행)이 **체크되어 있는지 확인**, 아니면 체크한다. 이게 켜져야 버튼이 "댓글 매칭 시작 → 영상까지 자동"이 되고 검토 화면에서 안 멈춘다.
|
||||
- `#autoRmsilence`(무음 제거)는 **사용자가 둔 상태 그대로 두고 건드리지 않는다**.
|
||||
- `#autoGo` 클릭
|
||||
4. **시작 확인**: 버튼이 "분석 중…"으로 바뀌고 진행 로그가 붙는지 스크린샷으로 확인한다. `alert`가 뜨면(URL 없음·JSON 없음) 입력이 빠진 것이므로 채우고 다시 클릭.
|
||||
5. **실행 중인 탭은 닫지 않는다** — 진행이 SSE(`/auto/stream/{aid}`)로 흐르므로 탭을 닫으면 중단된다. 작업 종료 보고 시 "탭 N개에서 영상 생성 진행 중"이라고 남기고 사용자가 닫도록 둔다.
|
||||
5. **보고**: 처리한 작업 수, 성공/실패 목록, h-lab `/shortform` 링크로 요약.
|
||||
|
||||
## 주의
|
||||
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
# Opal 출력 JSON 보정 — 문자열 값 안의 미이스케이프 따옴표를 \" 로 바꾼다.
|
||||
#
|
||||
# Opal이 title_top 같은 값에 따옴표를 그대로 뱉는 일이 있다(예: "title_top": ""나 진짜 방장하기 싫어...""").
|
||||
# 그대로 저장하면 ShortformOutputParser의 Jackson readTree가 던져서 status=FAILED, clips=0이 되고,
|
||||
# 캡컷 에이전트의 구간 JSON 붙여넣기도 같이 막힌다.
|
||||
#
|
||||
# 한 줄에 필드 하나씩 나오는 Opal 출력 형태를 전제로, `"key": "value"` 꼴 줄의
|
||||
# value 안쪽 따옴표만 이스케이프한다. 이미 정상인 JSON에는 아무 변화가 없다.
|
||||
#
|
||||
# 사용:
|
||||
# $fixed = & .\fix-opal-json.ps1 -Text $raw
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Text)
|
||||
|
||||
$lines = $Text -split "`r`n|`n|`r"
|
||||
$out = foreach ($ln in $lines) {
|
||||
if ($ln -match '^(\s*"[A-Za-z0-9_]+"\s*:\s*)"(.*)"(\s*,?)\s*$') {
|
||||
$pre = $Matches[1]; $val = $Matches[2]; $tail = $Matches[3]
|
||||
$val = [regex]::Replace($val, '(?<!\\)"', '\"')
|
||||
$pre + '"' + $val + '"' + $tail
|
||||
}
|
||||
else {
|
||||
$ln
|
||||
}
|
||||
}
|
||||
$out -join "`r`n"
|
||||
33
AGENTS.md
33
AGENTS.md
@ -1,33 +0,0 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
This is a Java 21, Spring Boot 3.4 Gradle application. Production code lives under `src/main/java/com/hlab/yanalyst`. Keep feature entities, repositories, services, controllers, and DTOs in `domain/<feature>/`; shared configuration, scheduling, errors, and response types belong in `global/`. Cross-feature integrations are in `service/`, while page controllers and general APIs are in `web/`.
|
||||
|
||||
Templates are in `src/main/resources/templates`, browser assets in `src/main/resources/static`, and application configuration in `src/main/resources/application*.yml`. Tests mirror production packages under `src/test/java`; fixtures belong in `src/test/resources`. Design notes and implementation plans live in `docs/superpowers/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
Use the checked-in Gradle wrapper; on Windows run:
|
||||
|
||||
- `.\gradlew.bat bootRun`: start the UI and API at `http://localhost:8088`.
|
||||
- `.\gradlew.bat test`: run the JUnit 5 test suite.
|
||||
- `.\gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.SrtFormatterTest"`: run one test class.
|
||||
- `.\gradlew.bat build`: compile, test, and create the application artifact.
|
||||
- `.\gradlew.bat clean build`: rebuild from clean output.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use four-space indentation and standard Java conventions: `PascalCase` types, `camelCase` members, and lowercase packages. Name Spring components by responsibility (`ChannelService`, `FeedController`, `ChannelRepository`) and DTOs with a `Dto` suffix. Use existing `ApiResponse<T>` wrappers for JSON endpoints. Keep Thymeleaf pages consistent with `layout/base.html` and reuse `static/css/variables.css`. No formatter is enforced, so match nearby code.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Tests use JUnit 5, Spring Boot Test, and Mockito. Name test classes `<Subject>Test` and test methods for observable behavior, such as `parsesShortLink`. Add unit tests for parsing and domain logic; use Spring context tests only when integration behavior requires them. There is no explicit coverage threshold, but changed logic should include regression coverage.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Recent commits use short prefixes such as `feat:` and `docs:`, followed by a concise Korean description. Keep each commit scoped to one change. Pull requests should explain the purpose, summarize implementation and verification, link relevant issues or design documents, and include screenshots for Thymeleaf/CSS changes. Call out configuration or database-schema effects explicitly.
|
||||
|
||||
## Security & Configuration
|
||||
|
||||
Copy `application-local.yml.example` to the ignored `application-local.yml` for local settings. Never commit database credentials, API keys, Google OAuth files, `tokens/`, logs, downloads, or generated media. Use environment variables such as `DB_URL`, `DB_USERNAME`, `DB_PASSWORD`, and `YOUTUBE_API_KEY`.
|
||||
@ -17,11 +17,6 @@ configurations {
|
||||
compileOnly {
|
||||
extendsFrom annotationProcessor
|
||||
}
|
||||
// Mockito 를 자바 에이전트로 명시 지정하기 위한 설정.
|
||||
// 지정하지 않으면 Mockito 가 JVM 에 self-attach 를 시도하는데, 이게 실패하면
|
||||
// "Could not initialize plugin: MockMaker" 로 목 기반 테스트가 전부 깨진다.
|
||||
// (JDK 21+ 는 self-attach 를 경고하고 이후 버전에서 막는다)
|
||||
mockitoAgent
|
||||
}
|
||||
|
||||
repositories {
|
||||
@ -43,7 +38,6 @@ dependencies {
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
mockitoAgent('org.mockito:mockito-core') { transitive = false }
|
||||
implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.9.0'
|
||||
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2'
|
||||
|
||||
@ -55,6 +49,4 @@ dependencies {
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
// self-attach 대신 에이전트를 직접 붙인다 (위 mockitoAgent 설정 참고)
|
||||
jvmArgs "-javaagent:${configurations.mockitoAgent.asPath}"
|
||||
}
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
# 예능 쇼츠 Opal 하이라이트 추출 지침
|
||||
|
||||
아래 내용을 Opal의 예능 영상 분석 지침으로 사용한다. Opal 분석 노드에는 `YouTube Video`와 `Timestamped VTT Transcript`를 함께 연결한다.
|
||||
|
||||
```text
|
||||
당신은 유튜브 영상에서 쇼츠(Shorts)로 제작했을 때 가장 터질 만한 구간을 찾아내는 바이럴 분석가입니다.
|
||||
|
||||
입력으로 다음 두 자료가 함께 제공됩니다.
|
||||
|
||||
- YouTube Video: 화자, 표정, 장면과 전체 맥락 확인용
|
||||
- Timestamped VTT Transcript: 구간 탐색과 모든 시간 결정용
|
||||
|
||||
두 입력을 분석하여 실제로 재미있고 쇼츠 원본 가치가 높은 하이라이트 후보만 선정하세요. 후보 수는 영상 내용에 따라 3~10개로 정하며, 수량을 맞추기 위해 평범한 구간을 넣지 마세요. 실제 자막의 시간과 내용이 일치하는 정확성이 자극적인 주제보다 중요합니다.
|
||||
|
||||
[입력 자료 우선순위 — 최우선 규칙]
|
||||
|
||||
1. start_time, end_time, hook_point는 반드시 Timestamped VTT Transcript에 실제로 적힌 타임스탬프만 사용하세요.
|
||||
2. YouTube Video의 재생 위치, 진행 막대 또는 영상 이해 결과로 시간을 추정하지 마세요.
|
||||
3. VTT에 존재하지 않는 시간을 만들거나 임의로 반올림하지 마세요.
|
||||
4. 영상과 VTT의 시간 정보가 충돌하면 VTT를 우선하세요.
|
||||
5. YouTube Video는 VTT의 화자, 표정, 장면과 전체 맥락을 보완하는 용도로만 사용하세요.
|
||||
6. Timestamped VTT Transcript가 입력되지 않았거나 비어 있거나 읽을 수 없으면 시간을 추정해 후보를 만들지 말고 다음 JSON만 출력하세요.
|
||||
|
||||
{"error":"TIMESTAMPED_VTT_REQUIRED","candidates":[]}
|
||||
|
||||
[제외 대상]
|
||||
|
||||
|
||||
[절대 작업 순서]
|
||||
|
||||
1. 제목이나 주제를 먼저 만들지 말고 VTT의 실제 자막 행과 타임스탬프부터 확인하세요.
|
||||
2. VTT에서 새 문장, 질문, 행동 또는 화제가 시작되는 자막 행을 start_time으로 선택하세요.
|
||||
3. VTT 자막을 시간순으로 따라가 펀치라인, 반전 또는 결론이 완전히 끝나는 마지막 자막 행의 종료 시각을 end_time으로 선택하세요.
|
||||
4. 핵심 후킹 요소가 시작되는 실제 VTT 자막 행의 시작 시각을 hook_point로 선택하세요.
|
||||
5. start_time, end_time, hook_point가 모두 VTT에 실제로 존재하는지 다시 확인하세요.
|
||||
6. 선택 구간 안의 실제 내용만 사용하여 reason, title_top, title_main을 마지막에 작성하세요.
|
||||
|
||||
영상의 다른 부분에서 발견한 인물, 사건, 대사, 장면 또는 자막을 현재 구간의 타임스탬프와 결합하지 마세요.
|
||||
|
||||
[구간 선정 규칙]
|
||||
|
||||
1. 각 구간은 최소 60초 이상이어야 합니다. 2분, 3분 또는 그 이상이어도 재미있는 흐름과 결말이 이어진다면 허용하세요. 정해진 최대 길이에 맞추려고 장면을 억지로 자르지 마세요.
|
||||
2. duration_sec에는 VTT의 end_time에서 start_time을 직접 뺀 초 단위 값을 기입하세요.
|
||||
3. start_time은 첫 자막 행의 시작 시각, end_time은 마지막 자막 행의 종료 시각을 사용하세요.
|
||||
4. 시작은 문장이나 화제의 자연스러운 시작점이어야 합니다. 문장 중간에서 시작하지 마세요.
|
||||
5. 종료는 핵심 발언, 펀치라인, 반전 또는 결론이 완전히 끝난 직후여야 합니다. 발언 중간에서 자르지 마세요.
|
||||
6. 핵심 장면 직전의 질문, 상황 설명 또는 빌드업을 구간 안에 포함하세요.
|
||||
7. 핵심 후킹 요소는 start_time 이후 15초 이내에 등장해야 하며 그 실제 VTT 자막 행의 시작 시각을 hook_point에 기입하세요.
|
||||
8. 시선을 끌 수 있는 갈등, 웃음, 반전 또는 핵심 정보가 포함된 구간을 우선하세요.
|
||||
9. 후보들은 서로 겹치지 않아야 하며 같은 장면이나 논점을 반복하지 마세요.
|
||||
10. 가능하면 영상의 초반, 중반, 후반에 골고루 분산하세요.
|
||||
11. 먼저 영상 전체에서 재미 강도가 높은 구간을 넓게 찾은 뒤 품질순으로 정렬하세요. 강한 구간이 3개뿐이면 3개만, 충분하면 최대 10개까지 출력하세요.
|
||||
12. 단순 정보 전달, 반복 대화, 긴 침묵, 맥락 없이는 이해되지 않는 장면은 후보 수를 채우기 위해 포함하지 마세요.
|
||||
|
||||
[타임스탬프 내부 검증]
|
||||
|
||||
이 검증 결과는 JSON에 출력하지 말고 후보를 확정하기 전에 내부적으로 수행하세요.
|
||||
|
||||
- start_time이 새 문장이나 화제의 첫 자막 행 시작 시각과 일치하는가?
|
||||
- end_time이 핵심 발언 또는 상황이 완결된 마지막 자막 행의 종료 시각인가?
|
||||
- hook_point가 실제 후킹 자막 행의 시작 시각인가?
|
||||
- start_time, end_time, hook_point가 모두 VTT에 실제로 적혀 있는가?
|
||||
- YouTube 영상 재생 위치를 근거로 시간을 추정하지 않았는가?
|
||||
- duration_sec가 end_time - start_time과 일치하며 최소 60초 이상인가?
|
||||
- hook_point가 start_time 이후 15초 이내인가?
|
||||
- 모든 후보가 서로 겹치지 않는가?
|
||||
- reason과 제목에 사용한 인물, 사건, 행동, 대사가 해당 구간 안에 실제로 존재하는가?
|
||||
|
||||
하나라도 확인되지 않으면 해당 후보를 폐기하고 실제 VTT에서 다른 구간을 찾으세요. 후보 수를 맞추려고 다른 구간의 내용이나 자막을 가져오지 마세요.
|
||||
|
||||
[시간 형식]
|
||||
|
||||
- 영상이 60분 미만이면 MM:SS 형식을 사용하세요.
|
||||
- 영상이 60분 이상이면 HH:MM:SS 형식을 사용하세요.
|
||||
- 60분 이상 영상에서 "34:48"은 잘못된 형식이고 "00:34:48"이 올바른 형식입니다.
|
||||
- VTT 시간을 임의로 반올림하거나 추정하지 마세요.
|
||||
|
||||
[제목 규칙]
|
||||
|
||||
- title_top은 공백 포함 최대 12자입니다.
|
||||
- title_main은 공백 포함 최대 14자입니다.
|
||||
- 한글, 숫자, 영문, 기호, 공백은 각각 1자로 계산하세요.
|
||||
- 두 제목은 같은 내용을 반복하지 말고 이어 읽었을 때 의미가 완성되게 하세요.
|
||||
- 개행 문자, 마침표, 불필요한 따옴표를 넣지 마세요.
|
||||
- 제목은 해당 구간에서 직접 확인되는 내용만 사용하세요.
|
||||
- 작성 후 글자 수를 직접 세고 제한을 넘으면 조사와 수식어를 제거해 다시 작성하세요.
|
||||
|
||||
[최종 출력 규칙]
|
||||
|
||||
유효한 JSON 객체 하나만 출력하세요. 설명, 마크다운, 주석, 검증 과정 또는 추가 필드를 출력하지 마세요.
|
||||
|
||||
candidates에는 품질 기준을 통과한 후보를 3~10개 넣으세요. id는 후보 수에 맞춰 반드시 1부터 빠짐없이 순서대로 작성하세요. 후보 수를 맞추기 위해 재미가 약한 구간을 추가하지 마세요.
|
||||
|
||||
각 후보에는 다음 9개 필드만 사용하세요.
|
||||
|
||||
- id
|
||||
- start_time
|
||||
- end_time
|
||||
- duration_sec
|
||||
- hook_point
|
||||
- boundary_check
|
||||
- reason
|
||||
- title_top
|
||||
- title_main
|
||||
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"id": 1,
|
||||
"start_time": "MM:SS",
|
||||
"end_time": "MM:SS",
|
||||
"duration_sec": 185,
|
||||
"hook_point": "MM:SS",
|
||||
"boundary_check": "질문 문장 시작 지점에서 시작, 답변 완결 후 종료",
|
||||
"reason": "구간 선정 이유 요약",
|
||||
"title_top": "짧은 상단 제목",
|
||||
"title_main": "핵심 메인 제목"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"start_time": "MM:SS",
|
||||
"end_time": "MM:SS",
|
||||
"duration_sec": 120,
|
||||
"hook_point": "MM:SS",
|
||||
"boundary_check": "문장 시작과 완결 지점 설명",
|
||||
"reason": "구간 선정 이유 요약",
|
||||
"title_top": "짧은 상단 제목",
|
||||
"title_main": "핵심 메인 제목"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"start_time": "MM:SS",
|
||||
"end_time": "MM:SS",
|
||||
"duration_sec": 120,
|
||||
"hook_point": "MM:SS",
|
||||
"boundary_check": "문장 시작과 완결 지점 설명",
|
||||
"reason": "구간 선정 이유 요약",
|
||||
"title_top": "짧은 상단 제목",
|
||||
"title_main": "핵심 메인 제목"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Opal 연결 구조
|
||||
|
||||
```text
|
||||
YouTube Video ─────────────┐
|
||||
├─ 하이라이트 분석 프롬프트
|
||||
Timestamped VTT Transcript ┘
|
||||
```
|
||||
|
||||
`Timestamped VTT Transcript`에는 숏폼 큐의 `타임스탬프 자막 복사`로 얻은 `WEBVTT` 전체를 입력한다.
|
||||
@ -1,154 +0,0 @@
|
||||
# 정치 쇼츠 Opal 하이라이트 추출 지침
|
||||
|
||||
아래 내용을 Opal의 영상 분석 지침으로 사용한다. 결과는 `capcut2`의 **정치 구간 JSON** 입력란에 바로 붙여 넣을 수 있는 최소 형식으로 출력한다.
|
||||
|
||||
```text
|
||||
당신은 정치·시사 유튜브 영상에서 쇼츠 원본으로 사용할 하이라이트 구간을 찾는 분석가입니다.
|
||||
|
||||
입력으로 다음 두 자료가 함께 제공됩니다.
|
||||
|
||||
- YouTube Video: 화자, 표정, 화면 자료와 전체 맥락 확인용
|
||||
- Timestamped VTT Transcript: 구간 탐색과 start_time·end_time 결정용
|
||||
|
||||
두 입력을 분석하여 정확히 5개의 서로 겹치지 않는 후보를 선정하세요. 실제 자막의 시간과 내용이 일치하는 정확성이 자극적인 주제보다 중요합니다.
|
||||
|
||||
[입력 자료 우선순위 — 최우선 규칙]
|
||||
|
||||
1. start_time과 end_time은 반드시 Timestamped VTT Transcript에 적힌 타임스탬프만 사용하세요.
|
||||
2. YouTube Video의 재생 위치, 진행 막대 또는 영상 이해 결과로 시간을 추정하지 마세요.
|
||||
3. VTT에 존재하지 않는 시간을 만들거나 임의로 반올림하지 마세요.
|
||||
4. 영상과 VTT의 시간 정보가 충돌하면 VTT를 우선하세요.
|
||||
5. YouTube Video는 VTT의 화자·표정·화면 맥락을 보완하는 용도로만 사용하세요.
|
||||
6. Timestamped VTT Transcript가 입력되지 않았거나 내용을 읽을 수 없으면 시간을 추정해 JSON을 만들지 말고 다음 JSON만 출력하세요.
|
||||
|
||||
{"error":"TIMESTAMPED_VTT_REQUIRED","candidates":[]}
|
||||
|
||||
[제외 대상]
|
||||
|
||||
|
||||
[절대 작업 순서]
|
||||
|
||||
1. 주제나 제목을 먼저 정하지 말고 VTT의 실제 자막 행과 타임스탬프부터 찾으세요.
|
||||
2. VTT에서 새 질문·주장·화제가 시작되는 자막 행을 시작점으로 선택하세요.
|
||||
3. VTT 자막을 시간순으로 따라가 핵심 결론이 완전히 끝나는 자막 행을 종료점으로 선택하세요.
|
||||
4. 선택 구간 전체를 다시 읽고 speaker와 target이 실제 내용과 맞는지 확인하세요.
|
||||
5. 구간 안의 실제 내용만 사용하여 title_top과 title_main을 마지막에 작성하세요.
|
||||
|
||||
영상의 다른 부분에서 발견한 인물·사건·수치·자막을 현재 구간의 타임스탬프와 결합하지 마세요.
|
||||
|
||||
[구간 선정 규칙]
|
||||
|
||||
1. 각 구간은 90~180초여야 합니다.
|
||||
2. start_time은 VTT 시작 자막 행의 시작 시각, end_time은 마지막 자막 행의 종료 시각을 사용하세요.
|
||||
3. 시작은 문장이나 화제의 자연스러운 시작점이어야 합니다.
|
||||
4. 종료는 핵심 발언과 결론이 완전히 끝난 직후여야 합니다. 발언 중간에서 자르지 마세요.
|
||||
5. 핵심 질문·수치·반박·폭로는 시작 후 15초 이내에 나와야 합니다.
|
||||
6. 질문과 답변을 분리하거나 앞뒤 맥락을 제거해 발언 의미를 바꾸지 마세요.
|
||||
7. 후보 5개는 서로 겹치지 않아야 하며 같은 논점을 반복하지 마세요.
|
||||
8. 가능하면 영상의 초반·중반·후반에 분산하세요.
|
||||
|
||||
[타임스탬프 내부 검증]
|
||||
|
||||
이 검증 결과는 JSON에 출력하지 말고 후보를 확정하기 전에 내부적으로 수행하세요.
|
||||
|
||||
- start_time 자막이 실제로 구간의 첫 문장과 일치하는가?
|
||||
- end_time 직전 자막에서 핵심 문장과 결론이 끝나는가?
|
||||
- start_time과 end_time이 모두 VTT에 실제로 적혀 있는가?
|
||||
- YouTube 영상 재생 위치를 근거로 시간을 추정하지 않았는가?
|
||||
- 해당 구간 안에 speaker와 target의 근거가 실제로 있는가?
|
||||
- 제목에 사용한 인명·기관·사건·행위·수치가 해당 구간 안에 실제로 있는가?
|
||||
- end_time - start_time이 90~180초인가?
|
||||
- 핵심 후킹 발언이 start_time 이후 15초 이내인가?
|
||||
|
||||
하나라도 확인되지 않으면 그 후보를 폐기하고 실제 자막에서 다른 구간을 찾으세요. 후보 수를 맞추려고 다른 구간의 내용이나 자막을 가져오지 마세요.
|
||||
|
||||
[정치 콘텐츠 표현 규칙]
|
||||
|
||||
1. 확정된 사실, 발언자의 주장, 언론 보도, 의혹, 가정과 인용을 구분하세요.
|
||||
2. 범죄·불법·외압·은폐·갑질 등의 의혹을 확인된 사실처럼 제목에 쓰지 마세요.
|
||||
3. 자동 자막 때문에 화자나 고유명사가 불명확하면 화면과 음성을 확인하고, 확인할 수 없으면 해당 구간을 제외하세요.
|
||||
4. 제목에는 "배신", "황당", "꼼수", "끝판왕", "파티", "펑펑", "자백", "초토화"처럼 편집자의 조롱이나 과장 표현을 사용하지 마세요.
|
||||
5. 의혹이나 인용 보도는 필요하면 "의혹", "주장", "보도", "?"를 사용해 사실 상태를 보존하세요.
|
||||
|
||||
[시간 형식]
|
||||
|
||||
- 영상이 60분 미만이면 MM:SS 형식을 사용하세요.
|
||||
- 영상이 60분 이상이면 HH:MM:SS 형식을 사용하세요.
|
||||
- 60분 이상 영상에서 "34:48"은 잘못된 형식이고 "00:34:48"이 올바른 형식입니다.
|
||||
- VTT 시간을 임의로 반올림하거나 추정하지 마세요.
|
||||
|
||||
[제목 규칙]
|
||||
|
||||
- title_top: 공백 포함 최대 9자, 권장 6~9자
|
||||
- title_main: 공백 포함 최대 12자, 권장 9~12자
|
||||
- 한글, 숫자, 영문, 기호, 공백은 각각 1자로 계산하세요.
|
||||
- 두 제목은 같은 내용을 반복하지 말고 이어 읽었을 때 의미가 완성되게 하세요.
|
||||
- 개행, 마침표, 불필요한 따옴표를 넣지 마세요.
|
||||
- 제목은 해당 구간에서 직접 확인되는 내용만 사용하세요.
|
||||
|
||||
[최종 출력 규칙]
|
||||
|
||||
유효한 JSON 객체 하나만 출력하세요. 설명, 마크다운, 주석, 검증 과정 또는 추가 필드를 출력하지 마세요.
|
||||
|
||||
각 후보에는 아래 7개 필드만 사용하세요.
|
||||
|
||||
- id
|
||||
- start_time
|
||||
- end_time
|
||||
- speaker
|
||||
- target
|
||||
- title_top
|
||||
- title_main
|
||||
|
||||
candidates에는 정확히 5개를 넣고 id는 반드시 1, 2, 3, 4, 5 순서로 작성하세요.
|
||||
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"id": 1,
|
||||
"start_time": "HH:MM:SS",
|
||||
"end_time": "HH:MM:SS",
|
||||
"speaker": "발언자 이름",
|
||||
"target": "질의 또는 비판 대상",
|
||||
"title_top": "짧은 보조제목",
|
||||
"title_main": "핵심 메인제목"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"start_time": "HH:MM:SS",
|
||||
"end_time": "HH:MM:SS",
|
||||
"speaker": "발언자 이름",
|
||||
"target": "질의 또는 비판 대상",
|
||||
"title_top": "짧은 보조제목",
|
||||
"title_main": "핵심 메인제목"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"start_time": "HH:MM:SS",
|
||||
"end_time": "HH:MM:SS",
|
||||
"speaker": "발언자 이름",
|
||||
"target": "질의 또는 비판 대상",
|
||||
"title_top": "짧은 보조제목",
|
||||
"title_main": "핵심 메인제목"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"start_time": "HH:MM:SS",
|
||||
"end_time": "HH:MM:SS",
|
||||
"speaker": "발언자 이름",
|
||||
"target": "질의 또는 비판 대상",
|
||||
"title_top": "짧은 보조제목",
|
||||
"title_main": "핵심 메인제목"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"start_time": "HH:MM:SS",
|
||||
"end_time": "HH:MM:SS",
|
||||
"speaker": "발언자 이름",
|
||||
"target": "질의 또는 비판 대상",
|
||||
"title_top": "짧은 보조제목",
|
||||
"title_main": "핵심 메인제목"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@ -1,77 +0,0 @@
|
||||
# shortform-queue 스킬 이식 가이드 (다른 PC용)
|
||||
|
||||
이 파일 하나만 가져가면 된다. 새 PC의 Claude Code에게 이렇게 말하면 끝:
|
||||
|
||||
> 이 md 읽고 shortform-queue 스킬 등록해줘
|
||||
|
||||
## Claude에게: 설치 절차
|
||||
|
||||
1. 아래 "SKILL.md 전문"을 **그대로** 스킬 파일로 저장한다.
|
||||
- h-lab 저장소가 있는 PC면(권장): `<h-lab 루트>/.claude/skills/shortform-queue/SKILL.md` — 사실 repo에 이미 포함되어 있으니 clone/pull만 됐다면 이 단계는 불필요.
|
||||
- repo 없이 전역으로 쓰려면: `~/.claude/skills/shortform-queue/SKILL.md`
|
||||
2. 저장 후 사용자에게 "스킬 등록 완료, '숏폼 큐 돌려줘' 또는 '1단계만 돌려줘'로 실행 가능"이라고 알린다.
|
||||
3. 아래 "PC별 확인사항"을 점검하고, 경로가 다르면 SKILL.md 안의 경로를 그 PC에 맞게 고친다.
|
||||
|
||||
## PC별 확인사항 (스킬이 돌아가기 위한 전제)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| h-lab 저장소 | `git clone https://h-git.tolag.shop/hehihoho3/h-lab.git` — 스킬이 localhost:8088 서버를 띄워야 함 |
|
||||
| JDK 21 | SKILL.md의 `JAVA_HOME=D:\Development\app\JDK\jdk-21.0.5`는 원래 PC 경로. 새 PC의 JDK 21 경로로 수정 |
|
||||
| DB 시크릿 | git-ignored `src/main/resources/application-local.yml` 필요 — 원래 PC에서 복사해올 것 (없으면 서버 기동 실패: "Failed to determine suitable jdbc url") |
|
||||
| 크롬 + Claude in Chrome 확장 | 브라우저 자동화(Opal 조작)에 필수. opal.google 사이트 권한 허용 |
|
||||
| 구글 로그인 | Opal은 구글 계정 로그인 필요 — 크롬에 로그인돼 있어야 함 |
|
||||
|
||||
## SKILL.md 전문
|
||||
|
||||
````markdown
|
||||
---
|
||||
name: shortform-queue
|
||||
description: h-lab 숏폼 큐의 대기 작업을 구글 Opal "숏폼 생성기"로 실행하고 결과를 DB에 저장. "숏폼 큐 돌려줘", "/shortform-queue", "숏폼 돌려줘", "1단계만 돌려줘" 요청 시 사용.
|
||||
---
|
||||
|
||||
# 숏폼 큐 실행 (Opal 브라우저 대행)
|
||||
|
||||
h-lab(localhost:8088)의 PENDING 작업을 Opal로 실행해 결과를 저장한다.
|
||||
h-lab 서버가 꺼져 있으면 먼저 `.\gradlew.bat bootRun`(JAVA_HOME=D:\Development\app\JDK\jdk-21.0.5)으로 띄운다.
|
||||
서버 시크릿은 git-ignored `application-local.yml` — 이 워크트리에 없으면 메인 워크트리(`D:\Development\InteliJ_repository\h-lab\src\main\resources\`)에서 복사.
|
||||
|
||||
## 모드 선택
|
||||
|
||||
| 요청 | 앱 | URL | 출력 형식 |
|
||||
|------|-----|-----|----------|
|
||||
| "숏폼 큐 돌려줘" (기본) | 숏폼 생성기 (풀버전) | `https://opal.google/edit/1w3mThvSpI3_skt4p4OAN3-M6-tnqz0HN` | `N=====` 구간 5개 + cuts JSON |
|
||||
| "1단계만 돌려줘", "숏폼 1단계" | 숏폼 생성기 1단계 | `https://opal.google/edit/1Z9zXUiBAjKKOJ08zXxAmE9Neliq86SEw` | `{"candidates":[...]}` JSON 하나 |
|
||||
|
||||
사용자가 Opal URL을 직접 주면 그 URL 우선. 파서(ShortformOutputParser)는 두 형식 모두 인식한다(88179bb) — 어느 모드든 저장하면 DONE + 클립 5개.
|
||||
|
||||
## 절차 (공통)
|
||||
|
||||
1. **큐 조회**: `GET http://localhost:8088/api/shortform/jobs?status=PENDING` → 비어있으면 "대기 작업 없음" 보고 후 종료.
|
||||
2. **Opal 접속**: 크롬 자동화로 모드에 맞는 편집 화면 접속. 로그인 풀려 있으면 사용자에게 로그인 요청.
|
||||
3. 각 PENDING 작업마다:
|
||||
a. 캔버스의 **YouTube Video 노드 클릭** → 우측 패널 URL 입력창을 비우고(전체선택 ctrl+a 후 덮어쓰기) 작업의 youtubeUrl 입력 → **Apply** (썸네일이 해당 영상으로 바뀌는지 확인).
|
||||
b. Preview 패널의 새로고침(리셋) 아이콘 → **Start** 클릭.
|
||||
c. **완료 폴링** 및 **추출**: 모드별 절차(아래) 참조.
|
||||
e. **저장**: 브라우저 fetch는 HTTPS→HTTP라 차단됨 — 추출 텍스트를 파일로 옮겨 셸에서 `POST http://localhost:8088/api/shortform/jobs/{id}/result` body `{"rawText": "<Output 원문>", "step1Text": "<Step 1 원문>"}`. PowerShell은 UTF-8 바이트로 전송(`[Text.Encoding]::UTF8.GetBytes`), 응답 한글이 깨져 보이는 건 PS 5.1 표시 문제이므로 검증은 curl로. 응답의 clips가 5개 미만이면 원문과 함께 보고.
|
||||
4. **에러 처리**: 특정 Step 노드가 빨간 표시로 실패하면 Preview 리셋 → Start로 1회 재실행. 재실패 시 해당 작업은 건너뛰고 사유를 보고(저장하지 않음 — PENDING 유지). 프롬프트 노드 끝에 중복 YouTube Video 칩이 생겼는지 확인(2026-08-03에 이 원인으로 ID 3이 항상 실패했음).
|
||||
5. **보고**: 처리한 작업 수, 성공/실패 목록, h-lab `/shortform` 링크로 요약.
|
||||
|
||||
## 풀버전 모드 (기본)
|
||||
|
||||
- **완료 폴링**: 40~60초 간격 스크린샷. "Thinking... Step N" 진행 표시가 사라지고 Output(1===== 형식)이 렌더링되면 완료. 전체 4~5분 소요(Step 3 다섯 개가 각 ~90초 병렬).
|
||||
- **Output 추출**: 앱 UI는 접근성 트리에 안 잡힌다. javascript_tool로 `document.getElementById('opal-app').contentDocument`에서 shadow DOM을 재귀 관통해 `"cuts"`를 포함한 **가장 긴** innerText를 찾는다. 텍스트가 크면 window 변수에 저장 후 900자 단위로 나눠 회수한다.
|
||||
- **Step 1 결과 추출**: Console 탭에서 "Step 1: 하이라이트 후보 구간 추출" 항목을 UI 클릭으로 펼친 뒤(lazy 렌더라 JS로 details.open만 하면 내용이 안 붙을 수 있음 — 안 되면 화면 좌표 클릭), shadow DOM 관통으로 `"candidates"`를 포함한 **가장 짧은** innerText(Model Response 부분)를 회수한다.
|
||||
|
||||
## 1단계 모드
|
||||
|
||||
- 노드 3개뿐: YouTube Video → Step 1(하이라이트 후보 5개) → Generate HTML. 전체 **~1분**이면 끝난다(10초 간격 폴링, "Thinking... Step 1: N%"가 사라지고 candidates JSON이 렌더링되면 완료).
|
||||
- **추출**: shadow DOM 관통으로 `"candidates"`를 포함한 **가장 긴** innerText 하나만 회수(~2KB, 통짜 회수 가능). Console 추출 불필요 — Output 자체가 Step 1 결과다.
|
||||
- **저장**: rawText와 step1Text에 **같은 텍스트**를 넣는다.
|
||||
|
||||
## 주의
|
||||
|
||||
- Opal은 무료 실험 서비스 — 일일 사용량 제한이 있으니 큐가 많으면 사용자에게 몇 개까지 돌릴지 확인.
|
||||
- Opal 앱 구조(노드 구성·프롬프트)가 바뀌어 보이면 조작을 멈추고 사용자에게 보고.
|
||||
- 쿠키 배너가 뜨면 "나중에"(거절)를 선택.
|
||||
````
|
||||
@ -1,135 +0,0 @@
|
||||
# 시드 채널 제목 키워드 필터 설계
|
||||
|
||||
작성일: 2026-08-11
|
||||
|
||||
## 배경
|
||||
|
||||
소재 피드(`/feed`)의 시드는 **채널 단위**다(`FeedService.addSeed` → `Channel.role = SOURCE|RIVAL`).
|
||||
디글 클래식(`@DiggleClassic`)처럼 한 채널이 여러 프로그램을 올리는 경우, 시드로 등록하면
|
||||
유 퀴즈 온 더 블럭뿐 아니라 놀면 뭐하니·코미디빅리그까지 전부 피드에 들어온다.
|
||||
|
||||
피드 화면의 "프로그램" 드롭다운도 도움이 안 된다. `ChannelVideoRepository.feedPrograms`가
|
||||
`ytChannelId`로 그룹핑하기 때문에 드롭다운에는 "디글 클래식" 한 줄만 뜨고,
|
||||
채널 **안에서** 특정 프로그램만 골라내는 수단이 없다.
|
||||
|
||||
## 목표
|
||||
|
||||
시드 채널에 제목 키워드를 걸어, 그 키워드에 걸리는 영상만 피드에 수집한다.
|
||||
|
||||
## 비목표
|
||||
|
||||
- 이미 수집된 영상의 소급 정리 (자동 삭제하지 않는다 — 아래 "알려진 동작" 참고)
|
||||
- 프로그램 단위 그룹핑/드롭다운 개편 (채널 단위 그룹핑을 유지한다)
|
||||
- 인물 추적(`PersonCollectionService`) 경로 변경
|
||||
|
||||
## 설계
|
||||
|
||||
### 1. 데이터
|
||||
|
||||
`Channel` 엔티티에 컬럼 하나를 추가한다.
|
||||
|
||||
```java
|
||||
/** 피드 수집 시 제목에 걸어둘 키워드(콤마 구분). 비어있으면 필터 없음 = 전부 수집. */
|
||||
@Column(name = "feed_title_filter", length = 255)
|
||||
private String feedTitleFilter;
|
||||
```
|
||||
|
||||
- `ddl-auto: update`라 기존 행은 `null`이 되고, `null`/공백은 "필터 없음"으로 해석된다.
|
||||
따라서 **기존 시드의 동작은 그대로**다.
|
||||
- 변경 메서드: `Channel.changeFeedTitleFilter(String)` — 공백 문자열은 `null`로 정규화한다.
|
||||
|
||||
### 2. 매칭 로직 — `FeedTitleFilter`
|
||||
|
||||
외부 의존이 없는 순수 클래스로 분리한다(`domain/channel/FeedTitleFilter.java`).
|
||||
`PersonPicks`, `VideoMetrics`와 같은 계열의 정적 유틸이다.
|
||||
|
||||
```java
|
||||
public static boolean matches(String filter, String title)
|
||||
```
|
||||
|
||||
규칙:
|
||||
|
||||
1. `filter`가 `null`/공백이면 **항상 `true`** (필터 없음).
|
||||
2. `filter`를 콤마로 나눈다. 빈 조각은 버린다. 유효 키워드가 하나도 없으면 `true`.
|
||||
3. 키워드와 제목 양쪽을 정규화한다: **소문자화 + 모든 공백류 제거**.
|
||||
4. 정규화된 제목이 정규화된 키워드 중 **하나라도** 포함하면 `true` (OR).
|
||||
5. `title`이 `null`/공백이면 `false` (필터가 있는 상태에서 제목을 못 읽으면 통과시키지 않는다).
|
||||
|
||||
공백 제거가 핵심이다. 실제 유튜브 제목 표기가 `[유퀴즈 온 더 블럭]`, `유 퀴즈 온 더 블럭`,
|
||||
`유퀴즈`로 흔들리는데, 공백을 지우면 키워드 `유퀴즈` 하나로 세 표기를 모두 잡는다.
|
||||
|
||||
### 3. 수집 적용점
|
||||
|
||||
`ChannelService.upsertVideos`에는 이미 길이·기간 필터가 `continue`로 붙어 있다
|
||||
(피드 경로에서만 값이 들어오고 일반 채널 동기화는 `null`을 넘긴다). 같은 자리에 한 줄을 더한다.
|
||||
|
||||
```java
|
||||
if (minDurationSec != null && (durationSec == null || durationSec < minDurationSec)) continue;
|
||||
if (publishedAfter != null && publishedAt.isBefore(publishedAfter)) continue;
|
||||
if (!FeedTitleFilter.matches(titleFilter, title)) continue; // 추가
|
||||
```
|
||||
|
||||
- `upsertVideos` 시그니처에 `String titleFilter` 파라미터를 추가한다.
|
||||
- `collectFeedVideos`가 `channel.getFeedTitleFilter()`를 꺼내 넘긴다.
|
||||
- 일반 채널 동기화 호출부는 `null`을 넘겨 **영향받지 않는다**.
|
||||
|
||||
**쿼터 영향 없음.** playlistItems 1페이지와 videos.list는 어차피 통째로 받고
|
||||
저장 단계에서만 거르는 구조라 채널당 2 units 그대로다.
|
||||
|
||||
### 4. API
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/feed/seeds` | 기존. body에 `titleFilter`(선택) 추가 |
|
||||
| `PATCH` | `/api/feed/seeds/{id}/filter` | 신설. body `{"titleFilter": "유퀴즈"}` — 빈 값이면 필터 해제 |
|
||||
|
||||
- `FeedService.addSeed(url, role, titleFilter)` — 기존 2-인자 오버로드는 `titleFilter=null`로 위임한다.
|
||||
- `FeedService.updateSeedFilter(id, titleFilter)` — 피드 시드가 아니면 `IllegalArgumentException`
|
||||
(`removeSeed`/`resetSeedFailure`와 동일한 방어).
|
||||
- `FeedSeedDto`에 `titleFilter` 필드를 추가해 목록 조회에서 현재 값이 보이게 한다.
|
||||
|
||||
### 5. UI (`templates/feed.html` 시드 관리 모달)
|
||||
|
||||
- 시드 추가 영역: URL·역할 아래에 `제목 키워드 (선택, 콤마 구분)` 입력칸 추가.
|
||||
도움말: "비워두면 채널의 롱폼을 전부 수집합니다. 예: `유퀴즈` — 한 채널에 여러 프로그램이 섞여 있을 때 사용하세요."
|
||||
- 시드 목록의 각 행: 필터가 있으면 채널명 아래 보조 줄에 `필터: 유퀴즈`를 표시한다.
|
||||
- 각 행에 필터 수정 버튼(연필 아이콘)을 추가한다. 클릭 시 `prompt()`로 현재 값을 채워 띄우고,
|
||||
확인하면 `PATCH`를 호출한다. 기존 코드가 `confirm()`을 쓰고 있어 일관된다.
|
||||
|
||||
### 6. 알려진 동작
|
||||
|
||||
필터를 **나중에** 건 경우, 이미 저장된 비매칭 영상은 삭제되지 않는다. 갱신 대상에서만 빠지고
|
||||
피드에는 계속 보인다. 재가공 텍스트·스크립트가 딸려 있을 수 있어 자동 삭제는 하지 않는다.
|
||||
완전히 밀려면 시드를 해제(연결된 영상 함께 삭제)한 뒤 필터를 지정해 재등록한다.
|
||||
|
||||
이 동작은 UI 도움말에 한 줄로 명시한다.
|
||||
|
||||
## 테스트
|
||||
|
||||
현재 `src/test` 디렉터리가 없다. 이번에 만든다.
|
||||
|
||||
`src/test/java/com/hlab/yanalyst/domain/channel/FeedTitleFilterTest.java` — 외부 API를 타지 않는
|
||||
순수 로직이라 테스트 가치가 가장 높은 지점이다. TDD로 이 클래스부터 작성한다.
|
||||
|
||||
케이스:
|
||||
|
||||
- 필터가 `null`/빈 문자열/콤마뿐이면 어떤 제목이든 통과
|
||||
- 공백 흔들림: 키워드 `유퀴즈` ↔ 제목 `[유 퀴즈 온 더 블럭] 123화`
|
||||
- 대소문자 무시: 키워드 `showcase` ↔ 제목 `SHOWCASE`
|
||||
- 콤마 OR: 키워드 `유퀴즈,핑계고` — 둘 중 하나만 걸려도 통과
|
||||
- 비매칭: 키워드 `유퀴즈` ↔ 제목 `놀면 뭐하니 190화` → `false`
|
||||
- 제목이 `null`이고 필터가 있으면 `false`
|
||||
- 키워드 조각의 앞뒤 공백은 무시 (`유퀴즈, 핑계고`)
|
||||
|
||||
`build.gradle`에 테스트 의존성(`spring-boot-starter-test`)이 이미 있는지 확인하고, 없으면 추가한다.
|
||||
|
||||
## 검증
|
||||
|
||||
```powershell
|
||||
$env:JAVA_HOME = "D:\Development\app\JDK\jdk-21.0.5"
|
||||
.\gradlew.bat test --tests "com.hlab.yanalyst.domain.channel.FeedTitleFilterTest"
|
||||
.\gradlew.bat build
|
||||
```
|
||||
|
||||
빌드 통과 후 앱을 띄워 `/feed` 시드 관리 모달에서 디글 클래식을 `유퀴즈` 필터로 등록하고
|
||||
수동 수집을 돌려 유퀴즈 영상만 들어오는지 확인한다.
|
||||
@ -3,12 +3,10 @@ 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.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableJpaAuditing
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
public class HLabApplication {
|
||||
|
||||
|
||||
@ -53,14 +53,6 @@ public class Channel {
|
||||
@Column(length = 10)
|
||||
private String role = ChannelRole.MY;
|
||||
|
||||
/** 피드 분류. 기존 null 행은 예능으로 간주한다. */
|
||||
@Column(name = "feed_topic", length = 20)
|
||||
private String feedTopic = FeedTopic.ENTERTAINMENT.name();
|
||||
|
||||
/** 시드별 수집 길이 규칙. 기존 null 행은 10분 이상 롱폼으로 간주한다. */
|
||||
@Column(name = "feed_format", length = 20)
|
||||
private String feedFormat = FeedFormat.LONG_FORM.name();
|
||||
|
||||
/**
|
||||
* 피드 수집 연속 실패 횟수. 3회 이상이면 자동 스킵해 쿼터 낭비를 막는다.
|
||||
* 성공하면 0으로 리셋된다.
|
||||
@ -117,19 +109,6 @@ public class Channel {
|
||||
return ChannelRole.normalize(this.role);
|
||||
}
|
||||
|
||||
public void configureFeed(String topic, String format) {
|
||||
this.feedTopic = FeedTopic.normalize(topic).name();
|
||||
this.feedFormat = FeedFormat.normalize(format).name();
|
||||
}
|
||||
|
||||
public FeedTopic feedTopicOrDefault() {
|
||||
return FeedTopic.normalize(this.feedTopic);
|
||||
}
|
||||
|
||||
public FeedFormat feedFormatOrDefault() {
|
||||
return FeedFormat.normalize(this.feedFormat);
|
||||
}
|
||||
|
||||
public int feedFailCountOrZero() {
|
||||
return this.feedFailCount == null ? 0 : this.feedFailCount;
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ package com.hlab.yanalyst.domain.channel;
|
||||
* <li>{@link #OWN} — 내가 운영하는 채널. 이미 발행한 결과물이라 소재가 아니므로
|
||||
* 수집함·칸반·발굴·피드 어디에도 섞이지 않고 내 채널 분석 화면에서만 쓰인다.</li>
|
||||
* <li>{@link #SOURCE} — 소재 원본 채널(웹예능 공식채널). 롱폼만 피드에 수집한다.</li>
|
||||
* <li>{@link #RIVAL} — 경쟁 채널(같은 소재를 다루는 클립 채널). 역시 롱폼만 피드에 수집한다.</li>
|
||||
* <li>{@link #RIVAL} — 경쟁 쇼츠 채널(같은 소재를 다루는 클립 채널). 쇼츠만 피드에 수집한다.</li>
|
||||
* </ul>
|
||||
*
|
||||
* ddl-auto:update 로 컬럼이 추가되므로 기존 행은 role 이 null 이다. null 은 항상 {@link #MY} 로 취급한다.
|
||||
@ -45,4 +45,9 @@ public final class ChannelRole {
|
||||
String r = normalize(role);
|
||||
return SOURCE.equals(r) || RIVAL.equals(r);
|
||||
}
|
||||
|
||||
/** 해당 역할이 수집할 영상 포맷 — SOURCE 는 롱폼만, RIVAL 은 쇼츠만. */
|
||||
public static boolean acceptsShorts(String role) {
|
||||
return RIVAL.equals(normalize(role));
|
||||
}
|
||||
}
|
||||
|
||||
@ -293,21 +293,19 @@ public class ChannelService {
|
||||
|
||||
private void processVideos(Channel channel, List<String> videoIds) {
|
||||
// 내 채널(OWN)의 영상은 소재가 아니라 성과라 출처를 달리해 수집함/발굴에서 격리한다.
|
||||
upsertVideos(channel, videoIds, ChannelRole.videoSource(channel.roleOrDefault()), null, null, null, null);
|
||||
upsertVideos(channel, videoIds, ChannelRole.videoSource(channel.roleOrDefault()), null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* videos.list 로 상세를 받아 ChannelVideo 를 upsert 한다.
|
||||
*
|
||||
* @param source 저장할 출처. CHANNEL(등록 채널) / SOURCE(소재 원본) / RIVAL(경쟁 채널)
|
||||
* @param minDurationSec 이 길이(초) 미만이거나 길이를 모르는 영상은 건너뜀(null 이면 전체)
|
||||
* @param source 저장할 출처. CHANNEL(등록 채널) / SOURCE(소재 원본) / RIVAL(경쟁 쇼츠)
|
||||
* @param shortsOnly null 이면 전체, TRUE 면 Shorts 만, FALSE 면 롱폼만 저장
|
||||
* @param publishedAfter 이 시각 이전 업로드는 건너뜀(null 이면 제한 없음)
|
||||
* @param newInsertsOut null 이 아니면 갱신이 아니라 새로 insert 된 영상을 여기 담아준다(골든타임 알림용)
|
||||
* @return 저장·갱신한 영상 수
|
||||
*/
|
||||
private int upsertVideos(Channel channel, List<String> videoIds, String source,
|
||||
Integer minDurationSec, Integer maxDurationSec, LocalDateTime publishedAfter,
|
||||
List<ChannelVideo> newInsertsOut) {
|
||||
Boolean shortsOnly, LocalDateTime publishedAfter) {
|
||||
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
|
||||
// status 는 임베드 가능 여부(embeddable) 때문에 필요하다. part 를 늘려도 쿼터는 그대로다.
|
||||
.queryParam("part", "snippet,statistics,contentDetails,status")
|
||||
@ -341,9 +339,8 @@ public class ChannelService {
|
||||
Integer durationSec = VideoMetrics.parseDurationSec(duration);
|
||||
Boolean isShorts = VideoMetrics.isShorts(durationSec);
|
||||
|
||||
// 길이/기간 필터 — 피드 수집에서만 사용(일반 채널 수집은 둘 다 null)
|
||||
if (minDurationSec != null && (durationSec == null || durationSec < minDurationSec)) continue;
|
||||
if (maxDurationSec != null && (durationSec == null || durationSec > maxDurationSec)) continue;
|
||||
// 포맷/기간 필터 — 피드 수집에서만 사용(일반 채널 수집은 둘 다 null)
|
||||
if (shortsOnly != null && shortsOnly != isShorts) continue;
|
||||
if (publishedAfter != null && publishedAt.isBefore(publishedAfter)) continue;
|
||||
|
||||
java.math.BigDecimal viewsPerHour = VideoMetrics.viewsPerHour(viewCount, publishedAt);
|
||||
@ -362,8 +359,7 @@ public class ChannelService {
|
||||
.ifPresentOrElse(v -> {
|
||||
v.update(title, thumbnailUrl, viewCount, likeCount);
|
||||
v.applyMetrics(durationSec, isShorts, viewsPerHour);
|
||||
v.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio,
|
||||
source, channel.feedTopicOrDefault());
|
||||
v.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source);
|
||||
v.applyHashtags(hashtags);
|
||||
v.applyEmbeddable(embeddable);
|
||||
channelVideoRepository.save(v);
|
||||
@ -379,12 +375,10 @@ public class ChannelService {
|
||||
.duration(duration)
|
||||
.build();
|
||||
newVideo.applyMetrics(durationSec, isShorts, viewsPerHour);
|
||||
newVideo.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio,
|
||||
source, channel.feedTopicOrDefault());
|
||||
newVideo.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source);
|
||||
newVideo.applyHashtags(hashtags);
|
||||
newVideo.applyEmbeddable(embeddable);
|
||||
channelVideoRepository.save(newVideo);
|
||||
if (newInsertsOut != null) newInsertsOut.add(newVideo);
|
||||
});
|
||||
saved++;
|
||||
}
|
||||
@ -396,18 +390,15 @@ public class ChannelService {
|
||||
|
||||
/**
|
||||
* 피드 시드 채널의 최근 업로드를 1페이지(최대 50건)만 수집한다.
|
||||
* 역할과 무관하게 {@code minDurationSec} 이상 롱폼만 저장한다 — 짧은 클립·쇼츠는 자를 원본이 못 된다.
|
||||
* 소스 채널은 롱폼만, 경쟁 채널은 쇼츠만 저장한다.
|
||||
*
|
||||
* @param channel SOURCE 또는 RIVAL 역할의 채널
|
||||
* @param publishedAfter 이 시각 이후 업로드만 수집
|
||||
* @param minDurationSec 이 길이(초) 미만은 수집하지 않음
|
||||
* @param newInsertsOut null 이 아니면 새로 insert 된 영상을 여기 담아준다(골든타임 알림용)
|
||||
* @return 저장·갱신된 영상 수
|
||||
* @throws IllegalStateException uploads 플레이리스트를 찾을 수 없을 때
|
||||
*/
|
||||
@Transactional
|
||||
public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter,
|
||||
List<ChannelVideo> newInsertsOut) {
|
||||
public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter) {
|
||||
String role = channel.roleOrDefault();
|
||||
String uploadsPlaylistId = channel.getUploadsPlaylistId();
|
||||
if (uploadsPlaylistId == null || uploadsPlaylistId.isBlank()) {
|
||||
@ -431,10 +422,10 @@ public class ChannelService {
|
||||
}
|
||||
if (videoIds.isEmpty()) return 0;
|
||||
|
||||
// SOURCE = 롱폼만(공식계정 쇼츠는 이미 잘린 결과물), RIVAL = 쇼츠만
|
||||
Boolean shortsOnly = ChannelRole.acceptsShorts(role);
|
||||
String source = ChannelRole.RIVAL.equals(role) ? ChannelRole.RIVAL : ChannelRole.SOURCE;
|
||||
FeedFormat format = channel.feedFormatOrDefault();
|
||||
return upsertVideos(channel, videoIds, source, format.minDurationSec(), format.maxDurationSec(),
|
||||
publishedAfter, newInsertsOut);
|
||||
return upsertVideos(channel, videoIds, source, shortsOnly, publishedAfter);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -15,7 +15,6 @@ import java.time.LocalDateTime;
|
||||
@Index(name = "idx_cv_channel_id", columnList = "channel_id"), // 채널별 영상
|
||||
@Index(name = "idx_cv_interest_status", columnList = "interest_status"), // 큐레이션/칸반 필터·집계
|
||||
@Index(name = "idx_cv_source", columnList = "source"), // 출처 필터·집계
|
||||
@Index(name = "idx_cv_feed_topic", columnList = "feed_topic"), // 예능/정치 피드 분리
|
||||
@Index(name = "idx_cv_category_id", columnList = "category_id"), // 카테고리 필터·집계
|
||||
@Index(name = "idx_cv_published_at", columnList = "published_at"), // 게시일 정렬·발굴 필터
|
||||
@Index(name = "idx_cv_views_per_sub_ratio", columnList = "views_per_sub_ratio") // 떡상 배율 정렬(발굴/후보)
|
||||
@ -67,10 +66,6 @@ public class ChannelVideo {
|
||||
@Column(name = "source", length = 20)
|
||||
private String source = "CHANNEL";
|
||||
|
||||
/** 피드 주제: ENTERTAINMENT | POLITICS. 기존 null 행은 예능으로 간주한다. */
|
||||
@Column(name = "feed_topic", length = 20)
|
||||
private String feedTopic = FeedTopic.ENTERTAINMENT.name();
|
||||
|
||||
/** 원본 YouTube 채널 ID(문자열). FK Channel 과 별개로 항상 보관. */
|
||||
@Column(name = "yt_channel_id")
|
||||
private String ytChannelId;
|
||||
@ -179,13 +174,12 @@ public class ChannelVideo {
|
||||
* @param source SOURCE(소재 원본 롱폼) | RIVAL(경쟁 쇼츠)
|
||||
*/
|
||||
public void applyFeedInfo(String ytChannelId, String channelTitle, Long subscriberCount,
|
||||
BigDecimal viewsPerSubRatio, String source, FeedTopic feedTopic) {
|
||||
BigDecimal viewsPerSubRatio, String source) {
|
||||
this.ytChannelId = ytChannelId;
|
||||
this.channelTitle = channelTitle;
|
||||
this.subscriberCount = subscriberCount;
|
||||
this.viewsPerSubRatio = viewsPerSubRatio;
|
||||
this.source = source;
|
||||
this.feedTopic = feedTopic == null ? FeedTopic.ENTERTAINMENT.name() : feedTopic.name();
|
||||
}
|
||||
|
||||
/** 설명에서 추출한 해시태그(쉼표 구분)를 채운다. */
|
||||
@ -248,11 +242,6 @@ public class ChannelVideo {
|
||||
if (this.source == null) this.source = "CHANNEL";
|
||||
if (this.interestStatus == null) this.interestStatus = "NEW";
|
||||
if (this.bookmarked == null) this.bookmarked = false;
|
||||
if (this.feedTopic == null) this.feedTopic = FeedTopic.ENTERTAINMENT.name();
|
||||
}
|
||||
|
||||
public FeedTopic feedTopicOrDefault() {
|
||||
return FeedTopic.normalize(this.feedTopic);
|
||||
}
|
||||
|
||||
/** 조회수 검색 결과로부터 수집 영상을 생성한다(채널 미연결). */
|
||||
|
||||
@ -33,7 +33,6 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
||||
* @param hideWorked true 면 아직 손대지 않은(NEW) 것만
|
||||
*/
|
||||
@Query("select v from ChannelVideo v where v.source = :source "
|
||||
+ "and coalesce(v.feedTopic, 'ENTERTAINMENT') = :topic "
|
||||
+ "and v.interestStatus <> 'EXCLUDED' "
|
||||
+ "and (cast(:publishedAfter as timestamp) is null or v.publishedAt >= :publishedAfter) "
|
||||
+ "and (:ytChannelId is null or v.ytChannelId = :ytChannelId) "
|
||||
@ -42,7 +41,6 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
||||
+ "and (:hideWorked = false or v.interestStatus = 'NEW') "
|
||||
+ "order by v.publishedAt desc")
|
||||
java.util.List<ChannelVideo> feed(@Param("source") String source,
|
||||
@Param("topic") String topic,
|
||||
@Param("publishedAfter") java.time.LocalDateTime publishedAfter,
|
||||
@Param("ytChannelId") String ytChannelId,
|
||||
@Param("minDurationSec") Integer minDurationSec,
|
||||
@ -52,14 +50,13 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
||||
|
||||
/**
|
||||
* 인물 탭 조회. source 가 아니라 matchedPerson 기준이라, 소스 채널에서 이미 수집한 영상이
|
||||
* 인물 검색에도 걸리면 두 탭 모두에 나타난다. 롱폼만 본다
|
||||
* (180초 컷 = {@link PersonPicks#SHORTS_MAX_SEC} — 3분짜리 쇼츠까지 걸러낸다).
|
||||
* 인물 검색에도 걸리면 두 탭 모두에 나타난다. 롱폼만 본다.
|
||||
*
|
||||
* @param person null 이면 추적 중인 인물 전체
|
||||
*/
|
||||
@Query("select v from ChannelVideo v where v.matchedPerson is not null "
|
||||
+ "and v.interestStatus <> 'EXCLUDED' "
|
||||
+ "and (v.durationSec is null or v.durationSec > 180) "
|
||||
+ "and (v.durationSec is null or v.durationSec > 65) "
|
||||
+ "and (cast(:publishedAfter as timestamp) is null or v.publishedAt >= :publishedAfter) "
|
||||
+ "and (:person is null or v.matchedPerson = :person) "
|
||||
+ "and (:hideWorked = false or v.interestStatus = 'NEW') "
|
||||
@ -76,10 +73,9 @@ public interface ChannelVideoRepository extends JpaRepository<ChannelVideo, Long
|
||||
|
||||
/** 피드 필터용 프로그램(채널) 목록: [ytChannelId, channelTitle, 영상수]. 영상 많은 순. */
|
||||
@Query("select v.ytChannelId, min(v.channelTitle), count(v) from ChannelVideo v "
|
||||
+ "where v.source = :source and coalesce(v.feedTopic, 'ENTERTAINMENT') = :topic "
|
||||
+ "and v.ytChannelId is not null "
|
||||
+ "where v.source = :source and v.ytChannelId is not null "
|
||||
+ "group by v.ytChannelId order by count(v) desc")
|
||||
java.util.List<Object[]> feedPrograms(@Param("source") String source, @Param("topic") String topic);
|
||||
java.util.List<Object[]> feedPrograms(@Param("source") String source);
|
||||
|
||||
/** 떡상 후보: 구독자 대비 조회수 비율이 높은 Shorts (제외 처리된 것은 빼고). 피드 수집물은 제외. */
|
||||
@Query("select v from ChannelVideo v where v.isShorts = true "
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
import com.hlab.yanalyst.global.notify.TelegramNotifier;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 골든타임 소재 텔레그램 알림.
|
||||
*
|
||||
* <p>수집에서 <b>새로 담긴</b> 영상 중 업로드 {@link FeedBadges#GOLDEN_HOURS}시간 이내인 것만 골라
|
||||
* 한 번에 알린다. 신규 insert 만 대상이라 같은 영상이 두 번 울리지 않는다.
|
||||
* 알림 실패는 수집을 깨지 않는다({@link TelegramNotifier}가 예외를 삼킨다).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FeedAlertService {
|
||||
|
||||
/** 한 메시지에 나열할 최대 영상 수(텔레그램 4096자 제한 여유). */
|
||||
private static final int MAX_LINES = 8;
|
||||
|
||||
private final TelegramNotifier telegramNotifier;
|
||||
|
||||
/**
|
||||
* @param header 메시지 제목 앞머리(예: "소재 피드", "인물 추적")
|
||||
* @param newVideos 이번 수집에서 새로 insert 된 영상들
|
||||
* @return 알림에 포함된 영상 수(텔레그램 미설정·해당 없음·발송 실패면 0)
|
||||
*/
|
||||
public int notifyGoldenTime(String header, List<ChannelVideo> newVideos) {
|
||||
if (newVideos == null || newVideos.isEmpty() || !telegramNotifier.isEnabled()) return 0;
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<ChannelVideo> golden = newVideos.stream()
|
||||
.filter(v -> FeedBadges.isGoldenTime(v.getPublishedAt(), now))
|
||||
.sorted(Comparator.comparing(ChannelVideo::getPublishedAt).reversed())
|
||||
.toList();
|
||||
if (golden.isEmpty()) return 0;
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("🔥 <b>").append(esc(header)).append(" 골든타임 ").append(golden.size())
|
||||
.append("건</b> — 아직 아무도 안 잘랐을 확률이 높아요");
|
||||
|
||||
int shown = Math.min(golden.size(), MAX_LINES);
|
||||
for (int i = 0; i < shown; i++) {
|
||||
ChannelVideo v = golden.get(i);
|
||||
sb.append("\n\n• <b>").append(esc(v.getChannelTitle())).append("</b> · ")
|
||||
.append(ago(v.getPublishedAt(), now))
|
||||
.append("\n <a href=\"https://www.youtube.com/watch?v=").append(esc(v.getVideoId()))
|
||||
.append("\">").append(esc(clip(v.getTitle()))).append("</a>");
|
||||
}
|
||||
if (golden.size() > shown) sb.append("\n\n…외 ").append(golden.size() - shown).append("건");
|
||||
sb.append("\n\nhttps://h-lab.tolag.shop/feed");
|
||||
|
||||
boolean sent = telegramNotifier.sendMessage(sb.toString());
|
||||
log.info("[Feed] 골든타임 알림: sent={}, count={}", sent, golden.size());
|
||||
return sent ? golden.size() : 0;
|
||||
}
|
||||
|
||||
private static String ago(LocalDateTime publishedAt, LocalDateTime now) {
|
||||
if (publishedAt == null) return "-";
|
||||
long hours = Duration.between(publishedAt, now).toHours();
|
||||
return hours < 1 ? "방금 업로드" : hours + "시간 전";
|
||||
}
|
||||
|
||||
private static String clip(String s) {
|
||||
if (s == null || s.isBlank()) return "(제목 없음)";
|
||||
return s.length() <= 70 ? s : s.substring(0, 69) + "…";
|
||||
}
|
||||
|
||||
/** HTML parse_mode 용 특수문자 이스케이프. */
|
||||
private static String esc(String s) {
|
||||
if (s == null) return "-";
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
}
|
||||
@ -18,8 +18,7 @@ import java.util.Map;
|
||||
/**
|
||||
* 소재 발굴 피드 수집기.
|
||||
*
|
||||
* <p>SOURCE(웹예능 공식채널)와 RIVAL(경쟁 클립 채널)의 신규 롱폼을 주기적으로 받아온다
|
||||
* (min-duration-sec 미만의 짧은 클립·쇼츠는 수집하지 않는다).
|
||||
* <p>SOURCE(웹예능 공식채널)의 신규 롱폼과 RIVAL(경쟁 예능짤 채널)의 신규 쇼츠를 주기적으로 받아온다.
|
||||
* uploads 플레이리스트 1페이지만 조회하므로 채널당 약 2 units — 검색(search.list 100 units)보다 훨씬 싸다.
|
||||
*/
|
||||
@Slf4j
|
||||
@ -33,7 +32,6 @@ public class FeedCollectionService {
|
||||
private final ChannelRepository channelRepository;
|
||||
private final ChannelService channelService;
|
||||
private final YoutubeQuotaGuard quotaGuard;
|
||||
private final FeedAlertService feedAlertService;
|
||||
|
||||
@Value("${hlab.feed.enabled:true}")
|
||||
private boolean enabled;
|
||||
@ -42,10 +40,6 @@ public class FeedCollectionService {
|
||||
@Value("${hlab.feed.period-days:14}")
|
||||
private int periodDays;
|
||||
|
||||
/** 이 길이(초) 미만은 수집하지 않는다 — 자를 원본이 못 되는 짧은 클립·쇼츠 차단. 기본 10분. */
|
||||
@Value("${hlab.feed.min-duration-sec:600}")
|
||||
private int minDurationSec;
|
||||
|
||||
/**
|
||||
* role 컬럼이 없던 시절의 기존 채널은 role 이 null 이다. 부팅 시 1회 MY 로 백필한다.
|
||||
* (ddl-auto:update 는 DEFAULT 를 채워주지 않으므로 애플리케이션에서 처리)
|
||||
@ -68,18 +62,10 @@ public class FeedCollectionService {
|
||||
|
||||
/** 수동/스케줄 공용. 모든 피드 시드를 쿼터 한도 안에서 수집하고 요약을 반환한다. */
|
||||
public Map<String, Object> collectAll() {
|
||||
return collect(null);
|
||||
}
|
||||
|
||||
/** 특정 주제만 즉시 수집한다. null은 스케줄러용 전체 수집이다. */
|
||||
public Map<String, Object> collect(FeedTopic topic) {
|
||||
List<Channel> seeds = channelRepository.findFeedSeeds().stream()
|
||||
.filter(c -> topic == null || c.feedTopicOrDefault() == topic)
|
||||
.toList();
|
||||
List<Channel> seeds = channelRepository.findFeedSeeds();
|
||||
LocalDateTime publishedAfter = LocalDateTime.now().minusDays(periodDays);
|
||||
|
||||
int ok = 0, failed = 0, skippedByQuota = 0, disabled = 0, saved = 0;
|
||||
List<ChannelVideo> goldenCandidates = new java.util.ArrayList<>();
|
||||
|
||||
for (Channel c : seeds) {
|
||||
if (c.isFeedDisabled()) {
|
||||
@ -92,10 +78,7 @@ public class FeedCollectionService {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
List<ChannelVideo> newOnes = new java.util.ArrayList<>();
|
||||
saved += channelService.collectFeedVideos(c, publishedAfter, newOnes);
|
||||
// 경쟁(RIVAL) 채널의 신규 영상은 경쟁자의 결과물이라 골든타임 알림 대상이 아니다
|
||||
if (ChannelRole.SOURCE.equals(c.roleOrDefault())) goldenCandidates.addAll(newOnes);
|
||||
saved += channelService.collectFeedVideos(c, publishedAfter);
|
||||
markSuccess(c.getId());
|
||||
ok++;
|
||||
} catch (Exception e) {
|
||||
@ -105,8 +88,6 @@ public class FeedCollectionService {
|
||||
}
|
||||
}
|
||||
|
||||
int goldenAlerted = feedAlertService.notifyGoldenTime("소재 피드", goldenCandidates);
|
||||
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("seeds", seeds.size());
|
||||
summary.put("collected", ok);
|
||||
@ -114,7 +95,6 @@ public class FeedCollectionService {
|
||||
summary.put("failed", failed);
|
||||
summary.put("autoDisabled", disabled);
|
||||
summary.put("skippedByQuota", skippedByQuota);
|
||||
summary.put("goldenAlerted", goldenAlerted);
|
||||
summary.put("quotaRemaining", quotaGuard.remaining());
|
||||
return summary;
|
||||
}
|
||||
|
||||
@ -29,13 +29,12 @@ public class FeedController {
|
||||
@Operation(summary = "피드 조회",
|
||||
description = "tab=SOURCE(롱폼 소재)|RIVAL(경쟁 쇼츠)|PERSON(인물 추적 롱폼). 항상 최신순.")
|
||||
public ApiResponse<List<FeedItemDto>> feed(@RequestParam(defaultValue = "SOURCE") String tab,
|
||||
@RequestParam(defaultValue = "ENTERTAINMENT") String topic,
|
||||
@RequestParam(required = false) Integer days,
|
||||
@RequestParam(required = false) String channelId,
|
||||
@RequestParam(required = false) String lengthBucket,
|
||||
@RequestParam(defaultValue = "false") boolean hideWorked,
|
||||
@RequestParam(required = false) String person) {
|
||||
return ApiResponse.ok(feedService.feed(tab, topic, days, channelId, lengthBucket, hideWorked, person));
|
||||
return ApiResponse.ok(feedService.feed(tab, days, channelId, lengthBucket, hideWorked, person));
|
||||
}
|
||||
|
||||
// --- 인물 추적 ---
|
||||
@ -74,25 +73,22 @@ public class FeedController {
|
||||
|
||||
@GetMapping("/programs")
|
||||
@Operation(summary = "프로그램 목록", description = "피드 필터용 원본 채널 목록(영상 많은 순)")
|
||||
public ApiResponse<List<FeedProgramDto>> programs(@RequestParam(defaultValue = "SOURCE") String tab,
|
||||
@RequestParam(defaultValue = "ENTERTAINMENT") String topic) {
|
||||
return ApiResponse.ok(feedService.programs(tab, topic));
|
||||
public ApiResponse<List<FeedProgramDto>> programs(@RequestParam(defaultValue = "SOURCE") String tab) {
|
||||
return ApiResponse.ok(feedService.programs(tab));
|
||||
}
|
||||
|
||||
@PostMapping("/collect")
|
||||
@Operation(summary = "수동 수집", description = "스케줄과 동일한 피드 수집을 즉시 1회 실행")
|
||||
public ApiResponse<Map<String, Object>> collect(
|
||||
@RequestParam(defaultValue = "ENTERTAINMENT") String topic) {
|
||||
return ApiResponse.ok(feedCollectionService.collect(FeedTopic.normalize(topic)));
|
||||
public ApiResponse<Map<String, Object>> collect() {
|
||||
return ApiResponse.ok(feedCollectionService.collectAll());
|
||||
}
|
||||
|
||||
// --- 시드(소스/경쟁 채널) 관리 ---
|
||||
|
||||
@GetMapping("/seeds")
|
||||
@Operation(summary = "시드 목록", description = "등록된 소재 원본·경쟁 채널")
|
||||
public ApiResponse<List<FeedSeedDto>> seeds(
|
||||
@RequestParam(defaultValue = "ENTERTAINMENT") String topic) {
|
||||
return ApiResponse.ok(feedService.seeds(topic));
|
||||
public ApiResponse<List<FeedSeedDto>> seeds() {
|
||||
return ApiResponse.ok(feedService.seeds());
|
||||
}
|
||||
|
||||
@PostMapping("/seeds")
|
||||
@ -100,9 +96,7 @@ public class FeedController {
|
||||
public ApiResponse<FeedSeedDto> addSeed(@RequestBody Map<String, String> body) {
|
||||
String url = body.get("url");
|
||||
if (url == null || url.isBlank()) throw new IllegalArgumentException("채널 URL 이 필요합니다.");
|
||||
return ApiResponse.created(feedService.addSeed(url, body.getOrDefault("role", ChannelRole.SOURCE),
|
||||
body.getOrDefault("topic", FeedTopic.ENTERTAINMENT.name()),
|
||||
body.getOrDefault("format", FeedFormat.LONG_FORM.name())));
|
||||
return ApiResponse.created(feedService.addSeed(url, body.getOrDefault("role", ChannelRole.SOURCE)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/seeds/{id}")
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
public enum FeedFormat {
|
||||
LONG_FORM(600, null),
|
||||
VIDEO(66, null),
|
||||
SHORTS(null, 65),
|
||||
ALL(null, null);
|
||||
|
||||
private final Integer minDurationSec;
|
||||
private final Integer maxDurationSec;
|
||||
|
||||
FeedFormat(Integer minDurationSec, Integer maxDurationSec) {
|
||||
this.minDurationSec = minDurationSec;
|
||||
this.maxDurationSec = maxDurationSec;
|
||||
}
|
||||
|
||||
public Integer minDurationSec() { return minDurationSec; }
|
||||
public Integer maxDurationSec() { return maxDurationSec; }
|
||||
|
||||
public static FeedFormat normalize(String value) {
|
||||
if (value == null || value.isBlank()) return LONG_FORM;
|
||||
try {
|
||||
return valueOf(value.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return LONG_FORM;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -37,10 +37,6 @@ public class FeedService {
|
||||
@org.springframework.beans.factory.annotation.Value("${hlab.feed.person.max-people:10}")
|
||||
private int maxPeople;
|
||||
|
||||
/** 수집 하한과 같은 값 — 이미 담겨 있는 더 짧은 영상도 화면에서 걸러 일관되게 보인다. */
|
||||
@org.springframework.beans.factory.annotation.Value("${hlab.feed.min-duration-sec:600}")
|
||||
private int minDurationSec;
|
||||
|
||||
/**
|
||||
* 피드 카드 목록(항상 최신순).
|
||||
*
|
||||
@ -52,16 +48,15 @@ public class FeedService {
|
||||
*/
|
||||
public List<FeedItemDto> feed(String tab, Integer days, String ytChannelId,
|
||||
String lengthBucket, boolean hideWorked) {
|
||||
return feed(tab, FeedTopic.ENTERTAINMENT.name(), days, ytChannelId, lengthBucket, hideWorked, null);
|
||||
return feed(tab, days, ytChannelId, lengthBucket, hideWorked, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param person 인물 탭에서 특정 인물만 (null/빈값이면 전체)
|
||||
*/
|
||||
public List<FeedItemDto> feed(String tab, String topic, Integer days, String ytChannelId,
|
||||
public List<FeedItemDto> feed(String tab, Integer days, String ytChannelId,
|
||||
String lengthBucket, boolean hideWorked, String person) {
|
||||
String source = normalizeTab(tab);
|
||||
String normalizedTopic = FeedTopic.normalize(topic).name();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime publishedAfter = (days == null || days <= 0) ? null : now.minusDays(days);
|
||||
String channelFilter = (ytChannelId == null || ytChannelId.isBlank()) ? null : ytChannelId;
|
||||
@ -84,12 +79,9 @@ public class FeedService {
|
||||
default -> { /* 미인식 값은 필터 없음 */ }
|
||||
}
|
||||
}
|
||||
// 수집 하한을 화면에도 적용 — 하한을 올렸을 때 과거에 담긴 짧은 영상이 남아 보이지 않게
|
||||
int topicFloor = FeedTopic.POLITICS.name().equals(normalizedTopic) ? 66 : minDurationSec;
|
||||
minSec = (minSec == null) ? Integer.valueOf(topicFloor) : Integer.valueOf(Math.max(minSec, topicFloor));
|
||||
|
||||
List<ChannelVideo> rows = channelVideoRepository.feed(
|
||||
source, normalizedTopic, publishedAfter, channelFilter, minSec, maxSec, hideWorked,
|
||||
source, publishedAfter, channelFilter, minSec, maxSec, hideWorked,
|
||||
PageRequest.of(0, MAX_ITEMS));
|
||||
|
||||
List<FeedItemDto> out = new ArrayList<>(rows.size());
|
||||
@ -98,25 +90,21 @@ public class FeedService {
|
||||
}
|
||||
|
||||
/** 필터 드롭다운용 프로그램(채널) 목록. */
|
||||
public List<FeedProgramDto> programs(String tab, String topic) {
|
||||
public List<FeedProgramDto> programs(String tab) {
|
||||
String source = normalizeTab(tab);
|
||||
String normalizedTopic = FeedTopic.normalize(topic).name();
|
||||
List<FeedProgramDto> out = new ArrayList<>();
|
||||
for (Object[] row : channelVideoRepository.feedPrograms(source, normalizedTopic)) {
|
||||
for (Object[] row : channelVideoRepository.feedPrograms(source)) {
|
||||
out.add(new FeedProgramDto((String) row[0], (String) row[1], (Long) row[2]));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 등록된 피드 시드 목록(소스 + 경쟁). */
|
||||
public List<FeedSeedDto> seeds(String topic) {
|
||||
FeedTopic normalizedTopic = FeedTopic.normalize(topic);
|
||||
public List<FeedSeedDto> seeds() {
|
||||
List<FeedSeedDto> out = new ArrayList<>();
|
||||
for (Channel c : channelRepository.findFeedSeeds()) {
|
||||
if (c.feedTopicOrDefault() != normalizedTopic) continue;
|
||||
out.add(new FeedSeedDto(c.getId(), c.getChannelId(), c.getTitle(), c.getThumbnailUrl(),
|
||||
c.getSubscriberCount(), c.roleOrDefault(), c.feedTopicOrDefault().name(),
|
||||
c.feedFormatOrDefault().name(), c.feedFailCountOrZero(), c.isFeedDisabled()));
|
||||
c.getSubscriberCount(), c.roleOrDefault(), c.feedFailCountOrZero(), c.isFeedDisabled()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@ -127,19 +115,17 @@ public class FeedService {
|
||||
* @param role SOURCE | RIVAL
|
||||
*/
|
||||
@Transactional
|
||||
public FeedSeedDto addSeed(String url, String role, String topic, String format) {
|
||||
public FeedSeedDto addSeed(String url, String role) {
|
||||
String normalized = ChannelRole.normalize(role);
|
||||
if (!ChannelRole.isFeed(normalized)) {
|
||||
throw new IllegalArgumentException("시드 역할은 SOURCE 또는 RIVAL 이어야 합니다: " + role);
|
||||
}
|
||||
Channel channel = channelService.saveChannelFromUrl(url);
|
||||
channel.changeRole(normalized);
|
||||
channel.configureFeed(topic, format);
|
||||
channel.resetFeedFailure();
|
||||
channelRepository.save(channel);
|
||||
return new FeedSeedDto(channel.getId(), channel.getChannelId(), channel.getTitle(),
|
||||
channel.getThumbnailUrl(), channel.getSubscriberCount(), normalized,
|
||||
channel.feedTopicOrDefault().name(), channel.feedFormatOrDefault().name(), 0, false);
|
||||
channel.getThumbnailUrl(), channel.getSubscriberCount(), normalized, 0, false);
|
||||
}
|
||||
|
||||
/** 시드 해제 — 채널과 그 채널에서 수집한 피드 영상을 함께 제거한다. */
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
public enum FeedTopic {
|
||||
ENTERTAINMENT,
|
||||
POLITICS;
|
||||
|
||||
public static FeedTopic normalize(String value) {
|
||||
if (value == null || value.isBlank()) return ENTERTAINMENT;
|
||||
try {
|
||||
return valueOf(value.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return ENTERTAINMENT;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -46,7 +46,6 @@ public class PersonCollectionService {
|
||||
private final ChannelRepository channelRepository;
|
||||
private final YoutubeQuotaGuard quotaGuard;
|
||||
private final RestTemplate restTemplate;
|
||||
private final FeedAlertService feedAlertService;
|
||||
|
||||
@Value("${youtube.api.key}")
|
||||
private String youtubeApiKey;
|
||||
@ -83,7 +82,6 @@ public class PersonCollectionService {
|
||||
|
||||
int searched = 0, saved = 0, skippedByQuota = 0, failed = 0;
|
||||
List<String> names = new ArrayList<>();
|
||||
List<ChannelVideo> newInserts = new ArrayList<>();
|
||||
|
||||
for (TrackedPerson p : people) {
|
||||
if (!quotaGuard.tryConsume(SEARCH_QUOTA + 1)) {
|
||||
@ -92,7 +90,7 @@ public class PersonCollectionService {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
int n = collectOne(p.getName(), exclude, publishedAfter, newInserts);
|
||||
int n = collectOne(p.getName(), exclude, publishedAfter);
|
||||
saved += n;
|
||||
searched++;
|
||||
names.add(p.getName());
|
||||
@ -103,28 +101,19 @@ public class PersonCollectionService {
|
||||
}
|
||||
}
|
||||
|
||||
int goldenAlerted = feedAlertService.notifyGoldenTime("인물 추적", newInserts);
|
||||
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("people", names);
|
||||
summary.put("searched", searched);
|
||||
summary.put("savedVideos", saved);
|
||||
summary.put("failed", failed);
|
||||
summary.put("skippedByQuota", skippedByQuota);
|
||||
summary.put("goldenAlerted", goldenAlerted);
|
||||
summary.put("quotaRemaining", quotaGuard.remaining());
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인물 1명 수집.
|
||||
*
|
||||
* @param newInsertsOut null 이 아니면 새로 insert 된 영상을 여기 담아준다(골든타임 알림용)
|
||||
* @return 새로 담거나 갱신한 롱폼 수
|
||||
*/
|
||||
/** 인물 1명 수집. @return 새로 담거나 갱신한 롱폼 수 */
|
||||
@Transactional
|
||||
public int collectOne(String person, Set<String> exclude, LocalDateTime publishedAfter,
|
||||
List<ChannelVideo> newInsertsOut) {
|
||||
public int collectOne(String person, Set<String> exclude, LocalDateTime publishedAfter) {
|
||||
String after = publishedAfter.atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT);
|
||||
|
||||
URI searchUri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/search")
|
||||
@ -171,7 +160,6 @@ public class PersonCollectionService {
|
||||
f.ytChannelId(), f.channelTitle(), f.durationSec(), vph, null, person);
|
||||
nv.applyEmbeddable(embeddable.get(f.videoId()));
|
||||
channelVideoRepository.save(nv);
|
||||
if (newInsertsOut != null) newInsertsOut.add(nv);
|
||||
});
|
||||
saved++;
|
||||
}
|
||||
|
||||
@ -24,13 +24,6 @@ public final class PersonPicks {
|
||||
"performance video", "mv", "m/v", "lyric video", "cover", "커버"
|
||||
);
|
||||
|
||||
/**
|
||||
* 이 길이 이하는 쇼츠로 보고 버린다. 유튜브 쇼츠가 최대 3분(180초)까지 허용되어
|
||||
* {@link VideoMetrics#isShorts}의 65초 컷으로는 1~3분짜리 쇼츠가 통과한다.
|
||||
* 어차피 3분 이하는 자를 원본으로도 부족하다.
|
||||
*/
|
||||
public static final int SHORTS_MAX_SEC = 180;
|
||||
|
||||
private PersonPicks() {}
|
||||
|
||||
/** 음악방송 무대·직캠·커버 영상인가(제목 기준). */
|
||||
@ -70,7 +63,7 @@ public final class PersonPicks {
|
||||
for (Found f : found) {
|
||||
if (f == null || f.videoId() == null || f.videoId().isBlank()) continue;
|
||||
// 쇼츠 제외 — 인물 추적의 목적은 자를 원본(롱폼)을 찾는 것
|
||||
if (f.durationSec() == null || f.durationSec() <= SHORTS_MAX_SEC) continue;
|
||||
if (f.durationSec() == null || VideoMetrics.isShorts(f.durationSec())) continue;
|
||||
if (f.ytChannelId() != null && excludeChannels != null
|
||||
&& excludeChannels.contains(f.ytChannelId())) continue;
|
||||
if (publishedAfter != null && f.publishedAt() != null
|
||||
|
||||
@ -8,5 +8,4 @@ package com.hlab.yanalyst.domain.channel.dto;
|
||||
* @param disabled 연속 실패 3회로 자동 스킵 중인지
|
||||
*/
|
||||
public record FeedSeedDto(Long id, String channelId, String title, String thumbnailUrl,
|
||||
Long subscriberCount, String role, String topic, String format,
|
||||
int failCount, boolean disabled) {}
|
||||
Long subscriberCount, String role, int failCount, boolean disabled) {}
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
public enum ShortformCategory {
|
||||
ENTERTAINMENT,
|
||||
POLITICS;
|
||||
|
||||
public static ShortformCategory normalize(ShortformCategory value) {
|
||||
return value == null ? ENTERTAINMENT : value;
|
||||
}
|
||||
}
|
||||
@ -20,13 +20,12 @@ public class ShortformController {
|
||||
|
||||
@PostMapping("/jobs")
|
||||
public ApiResponse<JobDetail> register(@RequestBody RegisterRequest request) {
|
||||
return ApiResponse.created(shortformService.register(request.youtubeUrl(), request.category()));
|
||||
return ApiResponse.created(shortformService.register(request.youtubeUrl()));
|
||||
}
|
||||
|
||||
@GetMapping("/jobs")
|
||||
public ApiResponse<List<JobSummary>> list(@RequestParam(required = false) ShortformJobStatus status,
|
||||
@RequestParam(required = false) ShortformCategory category) {
|
||||
return ApiResponse.ok(shortformService.list(status, category));
|
||||
public ApiResponse<List<JobSummary>> list(@RequestParam(required = false) ShortformJobStatus status) {
|
||||
return ApiResponse.ok(shortformService.list(status));
|
||||
}
|
||||
|
||||
@GetMapping("/jobs/{id}")
|
||||
@ -41,19 +40,7 @@ public class ShortformController {
|
||||
|
||||
@PostMapping("/import")
|
||||
public ApiResponse<JobDetail> importResult(@RequestBody ImportRequest request) {
|
||||
return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText(),
|
||||
request.step1Text(), request.category()));
|
||||
}
|
||||
|
||||
@PostMapping("/jobs/{id}/reset")
|
||||
public ApiResponse<JobDetail> reset(@PathVariable Long id) {
|
||||
return ApiResponse.ok(shortformService.reset(id));
|
||||
}
|
||||
|
||||
@PostMapping("/jobs/{id}/subtitles/retry")
|
||||
public ApiResponse<Long> retrySubtitles(@PathVariable Long id) {
|
||||
shortformService.retrySubtitle(id);
|
||||
return ApiResponse.ok(id);
|
||||
return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText(), request.step1Text()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/jobs/{id}")
|
||||
|
||||
@ -32,10 +32,6 @@ public class ShortformJob {
|
||||
@Column(nullable = false, length = 20)
|
||||
private String videoId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "content_category", length = 20)
|
||||
private ShortformCategory category = ShortformCategory.ENTERTAINMENT;
|
||||
|
||||
/** 결과의 첫 클립 title_main. 목록 표시에 쓴다. */
|
||||
@Column(length = 300)
|
||||
private String title;
|
||||
@ -57,70 +53,17 @@ public class ShortformJob {
|
||||
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "subtitle_status", length = 20)
|
||||
private SubtitleStatus subtitleStatus = SubtitleStatus.NOT_REQUESTED;
|
||||
|
||||
@Column(name = "subtitle_vtt", columnDefinition = "TEXT")
|
||||
private String subtitleVtt;
|
||||
|
||||
@Column(name = "subtitle_error", length = 500)
|
||||
private String subtitleError;
|
||||
|
||||
@OneToMany(mappedBy = "job", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@OrderBy("clipNo ASC")
|
||||
private List<ShortformClip> clips = new ArrayList<>();
|
||||
|
||||
public static ShortformJob create(String youtubeUrl, String videoId) {
|
||||
return create(youtubeUrl, videoId, ShortformCategory.ENTERTAINMENT);
|
||||
}
|
||||
|
||||
public static ShortformJob create(String youtubeUrl, String videoId, ShortformCategory category) {
|
||||
ShortformJob job = new ShortformJob();
|
||||
job.youtubeUrl = youtubeUrl;
|
||||
job.videoId = videoId;
|
||||
job.category = ShortformCategory.normalize(category);
|
||||
return job;
|
||||
}
|
||||
|
||||
public void changeCategory(ShortformCategory category) {
|
||||
this.category = ShortformCategory.normalize(category);
|
||||
}
|
||||
|
||||
public ShortformCategory categoryOrDefault() {
|
||||
return ShortformCategory.normalize(this.category);
|
||||
}
|
||||
|
||||
public SubtitleStatus subtitleStatusOrDefault() {
|
||||
return subtitleStatus == null ? SubtitleStatus.NOT_REQUESTED : subtitleStatus;
|
||||
}
|
||||
|
||||
public void markSubtitleCollecting() {
|
||||
markSubtitleCollecting(null);
|
||||
}
|
||||
|
||||
public void markSubtitleCollecting(String message) {
|
||||
subtitleStatus = SubtitleStatus.COLLECTING;
|
||||
subtitleError = message;
|
||||
}
|
||||
|
||||
public void storeSubtitle(String vtt) {
|
||||
subtitleVtt = vtt;
|
||||
subtitleStatus = SubtitleStatus.READY;
|
||||
subtitleError = null;
|
||||
}
|
||||
|
||||
public void markSubtitleUnavailable(String message) {
|
||||
subtitleStatus = SubtitleStatus.UNAVAILABLE;
|
||||
subtitleError = message;
|
||||
}
|
||||
|
||||
public void markSubtitleFailed(String message) {
|
||||
subtitleStatus = SubtitleStatus.FAILED;
|
||||
String detail = message == null ? "자막 수집 실패" : message;
|
||||
subtitleError = detail.substring(0, Math.min(detail.length(), 500));
|
||||
}
|
||||
|
||||
public void applyResult(String raw, List<ParsedClip> parsed) {
|
||||
this.rawOutput = raw;
|
||||
this.clips.clear();
|
||||
@ -143,17 +86,4 @@ public class ShortformJob {
|
||||
this.status = ShortformJobStatus.FAILED;
|
||||
this.completedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과를 비우고 다시 대기 상태로 되돌린다. 등록 정보(URL·videoId·등록시각)는 유지하므로
|
||||
* 삭제 후 재등록할 필요가 없고, 큐 스킬이 PENDING 만 집어가므로 그대로 다시 실행된다.
|
||||
*/
|
||||
public void reset() {
|
||||
this.rawOutput = null;
|
||||
this.step1Output = null;
|
||||
this.title = null;
|
||||
this.completedAt = null;
|
||||
this.clips.clear(); // orphanRemoval 로 클립 행도 함께 지워진다
|
||||
this.status = ShortformJobStatus.PENDING;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@ -11,15 +9,4 @@ public interface ShortformJobRepository extends JpaRepository<ShortformJob, Long
|
||||
Optional<ShortformJob> findByVideoId(String videoId);
|
||||
List<ShortformJob> findAllByOrderByCreatedAtDesc();
|
||||
List<ShortformJob> findByStatusOrderByCreatedAtAsc(ShortformJobStatus status);
|
||||
List<ShortformJob> findBySubtitleStatus(SubtitleStatus subtitleStatus);
|
||||
@Query("select j from ShortformJob j where coalesce(j.category, "
|
||||
+ "com.hlab.yanalyst.domain.shortform.ShortformCategory.ENTERTAINMENT) = :category "
|
||||
+ "order by j.createdAt desc")
|
||||
List<ShortformJob> findByCategoryOrderByCreatedAtDesc(@Param("category") ShortformCategory category);
|
||||
|
||||
@Query("select j from ShortformJob j where coalesce(j.category, "
|
||||
+ "com.hlab.yanalyst.domain.shortform.ShortformCategory.ENTERTAINMENT) = :category "
|
||||
+ "and j.status = :status order by j.createdAt asc")
|
||||
List<ShortformJob> findByCategoryAndStatusOrderByCreatedAtAsc(
|
||||
@Param("category") ShortformCategory category, @Param("status") ShortformJobStatus status);
|
||||
}
|
||||
|
||||
@ -40,49 +40,9 @@ public class ShortformOutputParser {
|
||||
ParsedClip clip = parseSection(nos.get(i), raw.substring(starts.get(i), to));
|
||||
if (clip != null) result.add(clip);
|
||||
}
|
||||
if (result.isEmpty()) result.addAll(parseCandidates(raw));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* "숏폼 생성기 1단계" 앱 출력: N===== 구분자 없이 {"candidates":[...]} JSON 하나.
|
||||
* 후보마다 원본 JSON을 capcutJson 자리에 보존하고, 시간 정보는 durationTable 로 요약한다.
|
||||
*/
|
||||
private List<ParsedClip> parseCandidates(String raw) {
|
||||
List<ParsedClip> result = new ArrayList<>();
|
||||
String json = extractJson(raw);
|
||||
if (json == null) return result;
|
||||
try {
|
||||
JsonNode arr = OM.readTree(json).path("candidates");
|
||||
int fallbackNo = 0;
|
||||
for (JsonNode c : arr) {
|
||||
fallbackNo++;
|
||||
String table = candidateTable(c);
|
||||
result.add(new ParsedClip(
|
||||
c.path("id").asInt(fallbackNo),
|
||||
OM.writerWithDefaultPrettyPrinter().writeValueAsString(c),
|
||||
null,
|
||||
table,
|
||||
c.path("title_top").asText(null),
|
||||
c.path("title_main").asText(null)));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// candidates 형식이 아니면 빈 리스트 — 원문은 rawOutput에 보존된다
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String candidateTable(JsonNode c) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("구간 ").append(c.path("start_time").asText("?"))
|
||||
.append(" ~ ").append(c.path("end_time").asText("?"))
|
||||
.append(" (").append(c.path("duration_sec").asText("?")).append("초)")
|
||||
.append(" · 훅 ").append(c.path("hook_point").asText("?"));
|
||||
if (c.hasNonNull("boundary_check")) sb.append("\n경계: ").append(c.path("boundary_check").asText());
|
||||
if (c.hasNonNull("reason")) sb.append("\n선정 이유: ").append(c.path("reason").asText());
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private ParsedClip parseSection(int no, String body) {
|
||||
String json = extractJson(body);
|
||||
String titles = extractBetween(body, "타이틀 후보", "검산표");
|
||||
|
||||
@ -3,74 +3,28 @@ package com.hlab.yanalyst.domain.shortform;
|
||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobDetail;
|
||||
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ShortformService {
|
||||
|
||||
private final ShortformJobRepository jobRepository;
|
||||
private final ShortformOutputParser parser;
|
||||
private final ShortformSubtitleService subtitleService;
|
||||
|
||||
/** 배포·재시작으로 중단된 비동기 자막 수집을 애플리케이션 기동 후 다시 시작한다. */
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void recoverInterruptedSubtitleCollections() {
|
||||
List<ShortformJob> interrupted = jobRepository.findBySubtitleStatus(SubtitleStatus.COLLECTING);
|
||||
if (!interrupted.isEmpty()) {
|
||||
log.info("중단된 숏폼 자막 수집 {}건 재개", interrupted.size());
|
||||
}
|
||||
interrupted.forEach(job -> subtitleService.collectAsync(job.getId()));
|
||||
}
|
||||
|
||||
/** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */
|
||||
@Transactional
|
||||
public JobDetail register(String youtubeUrl) {
|
||||
return register(youtubeUrl, ShortformCategory.ENTERTAINMENT);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public JobDetail register(String youtubeUrl, ShortformCategory category) {
|
||||
String videoId = YoutubeVideoId.from(youtubeUrl)
|
||||
.orElseThrow(() -> new IllegalArgumentException("유튜브 URL이 아닙니다: " + youtubeUrl));
|
||||
ShortformJob job = jobRepository.findByVideoId(videoId)
|
||||
.orElseGet(() -> jobRepository.save(ShortformJob.create(youtubeUrl.trim(), videoId, category)));
|
||||
job.changeCategory(category);
|
||||
if (job.subtitleStatusOrDefault() != SubtitleStatus.READY
|
||||
&& job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) {
|
||||
job.markSubtitleCollecting("자막 수집 대기 중");
|
||||
scheduleSubtitleAfterCommit(job.getId());
|
||||
}
|
||||
.orElseGet(() -> jobRepository.save(ShortformJob.create(youtubeUrl.trim(), videoId)));
|
||||
return JobDetail.from(job);
|
||||
}
|
||||
|
||||
private void scheduleSubtitleAfterCommit(Long jobId) {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override public void afterCommit() { subtitleService.collectAsync(jobId); }
|
||||
});
|
||||
} else {
|
||||
subtitleService.collectAsync(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void retrySubtitle(Long jobId) {
|
||||
ShortformJob job = jobRepository.findById(jobId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
||||
job.markSubtitleCollecting("자막 재수집 대기 중");
|
||||
scheduleSubtitleAfterCommit(jobId);
|
||||
}
|
||||
|
||||
/** Opal 출력 원문을 파싱해 저장. 클립이 하나도 안 나오면 FAILED로 남긴다. */
|
||||
@Transactional
|
||||
public JobDetail saveResult(Long jobId, String rawText, String step1Text) {
|
||||
@ -88,23 +42,16 @@ public class ShortformService {
|
||||
|
||||
/** 등록 + 결과 저장 한 번에 (Opal 수동 실행 후 붙여넣기 경로). */
|
||||
@Transactional
|
||||
public JobDetail importResult(String youtubeUrl, String rawText, String step1Text, ShortformCategory category) {
|
||||
JobDetail registered = register(youtubeUrl, category);
|
||||
public JobDetail importResult(String youtubeUrl, String rawText, String step1Text) {
|
||||
JobDetail registered = register(youtubeUrl);
|
||||
return saveResult(registered.id(), rawText, step1Text);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<JobSummary> list(ShortformJobStatus status, ShortformCategory category) {
|
||||
List<ShortformJob> jobs;
|
||||
if (category == null) {
|
||||
jobs = (status == null)
|
||||
? jobRepository.findAllByOrderByCreatedAtDesc()
|
||||
: jobRepository.findByStatusOrderByCreatedAtAsc(status);
|
||||
} else {
|
||||
jobs = (status == null)
|
||||
? jobRepository.findByCategoryOrderByCreatedAtDesc(category)
|
||||
: jobRepository.findByCategoryAndStatusOrderByCreatedAtAsc(category, status);
|
||||
}
|
||||
public List<JobSummary> list(ShortformJobStatus status) {
|
||||
List<ShortformJob> jobs = (status == null)
|
||||
? jobRepository.findAllByOrderByCreatedAtDesc()
|
||||
: jobRepository.findByStatusOrderByCreatedAtAsc(status);
|
||||
return jobs.stream().map(JobSummary::from).toList();
|
||||
}
|
||||
|
||||
@ -118,16 +65,4 @@ public class ShortformService {
|
||||
public void delete(Long jobId) {
|
||||
jobRepository.deleteById(jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과를 비우고 다시 대기(PENDING)로 되돌린다. 큐 스킬이 PENDING 만 집어가므로
|
||||
* 이 호출만으로 재실행 대상이 된다 — 삭제 후 URL 재등록이 필요 없다.
|
||||
*/
|
||||
@Transactional
|
||||
public JobDetail reset(Long jobId) {
|
||||
ShortformJob job = jobRepository.findById(jobId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
||||
job.reset();
|
||||
return JobDetail.from(job);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,206 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
import com.hlab.yanalyst.global.notify.TelegramNotifier;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** 숏폼 큐 영상의 YouTube 자막만 yt-dlp로 수집한다. 영상 파일은 내려받지 않는다. */
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ShortformSubtitleService {
|
||||
|
||||
private static final Pattern VIDEO_ID = Pattern.compile("^[A-Za-z0-9_-]{11}$");
|
||||
private static final int MAX_COLLECTION_ATTEMPTS = 2;
|
||||
|
||||
private final ShortformJobRepository jobRepository;
|
||||
private final ShortformWhisperService whisperService;
|
||||
private final TelegramNotifier telegramNotifier;
|
||||
|
||||
@Value("${ytdlp.bin:yt-dlp}")
|
||||
private String ytdlpBin;
|
||||
|
||||
@Value("${ytdlp.timeout-seconds:600}")
|
||||
private long timeoutSeconds;
|
||||
|
||||
@Value("${download.dir:downloads}")
|
||||
private String downloadDir;
|
||||
|
||||
@Async
|
||||
public void collectAsync(Long jobId) {
|
||||
ShortformJob job = jobRepository.findById(jobId).orElse(null);
|
||||
if (job == null || job.subtitleStatusOrDefault() == SubtitleStatus.READY) {
|
||||
return;
|
||||
}
|
||||
job.markSubtitleCollecting("YouTube 자막 확인 중");
|
||||
jobRepository.save(job);
|
||||
|
||||
try {
|
||||
String vtt = collectOrTranscribe(jobId, job.getVideoId());
|
||||
ShortformJob current = jobRepository.findById(jobId).orElse(null);
|
||||
if (current == null) return;
|
||||
if (vtt == null) current.markSubtitleUnavailable("YouTube/Whisper 자막을 생성하지 못했습니다");
|
||||
else current.storeSubtitle(vtt);
|
||||
jobRepository.save(current);
|
||||
if (vtt == null) notifyFailure(current, current.getSubtitleError());
|
||||
else notifyReady(current);
|
||||
} catch (Exception e) {
|
||||
log.warn("숏폼 큐 자막 수집 실패: jobId={}, videoId={}", jobId, job.getVideoId(), e);
|
||||
jobRepository.findById(jobId).ifPresent(current -> {
|
||||
current.markSubtitleFailed(e.getMessage());
|
||||
jobRepository.save(current);
|
||||
notifyFailure(current, current.getSubtitleError());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyReady(ShortformJob job) {
|
||||
telegramNotifier.sendMessage("✅ <b>숏폼 자막 생성 완료</b>\n"
|
||||
+ "분류: " + categoryLabel(job) + "\n"
|
||||
+ "영상: <code>" + job.getVideoId() + "</code>\n"
|
||||
+ "<a href=\"https://h-lab.tolag.shop/shortform?category="
|
||||
+ job.categoryOrDefault().name() + "\">숏폼 큐에서 확인</a>");
|
||||
}
|
||||
|
||||
private void notifyFailure(ShortformJob job, String reason) {
|
||||
telegramNotifier.sendMessage("❌ <b>숏폼 자막 생성 실패</b>\n"
|
||||
+ "분류: " + categoryLabel(job) + "\n"
|
||||
+ "영상: <code>" + job.getVideoId() + "</code>\n"
|
||||
+ "사유: " + escapeHtml(reason) + "\n"
|
||||
+ "<a href=\"https://h-lab.tolag.shop/shortform?category="
|
||||
+ job.categoryOrDefault().name() + "\">숏폼 큐에서 확인</a>");
|
||||
}
|
||||
|
||||
private static String categoryLabel(ShortformJob job) {
|
||||
return job.categoryOrDefault() == ShortformCategory.POLITICS ? "정치" : "예능";
|
||||
}
|
||||
|
||||
private static String escapeHtml(String value) {
|
||||
if (value == null || value.isBlank()) return "알 수 없는 오류";
|
||||
return value.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
|
||||
String collectOrTranscribe(Long jobId, String videoId) throws IOException, InterruptedException {
|
||||
try {
|
||||
String vtt = collectWithRetry(videoId);
|
||||
if (vtt != null) return vtt;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.warn("YouTube VTT 수집 실패, Whisper 폴백 실행: videoId={}", videoId, e);
|
||||
}
|
||||
updateProgress(jobId, "YouTube 자막 없음 · Whisper 자막 생성 중");
|
||||
return whisperService.transcribe(videoId);
|
||||
}
|
||||
|
||||
private void updateProgress(Long jobId, String message) {
|
||||
jobRepository.findById(jobId).ifPresent(current -> {
|
||||
current.markSubtitleCollecting(message);
|
||||
jobRepository.save(current);
|
||||
});
|
||||
}
|
||||
|
||||
String collectWithRetry(String videoId) throws IOException, InterruptedException {
|
||||
Exception lastFailure = null;
|
||||
for (int attempt = 1; attempt <= MAX_COLLECTION_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
String vtt = collect(videoId);
|
||||
if (vtt != null) return vtt;
|
||||
log.info("숏폼 큐 자막 없음: videoId={}, attempt={}/{}",
|
||||
videoId, attempt, MAX_COLLECTION_ATTEMPTS);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
lastFailure = e;
|
||||
log.warn("숏폼 큐 자막 수집 재시도: videoId={}, attempt={}/{}",
|
||||
videoId, attempt, MAX_COLLECTION_ATTEMPTS, e);
|
||||
}
|
||||
}
|
||||
if (lastFailure instanceof IOException ioException) throw ioException;
|
||||
if (lastFailure instanceof RuntimeException runtimeException) throw runtimeException;
|
||||
return null;
|
||||
}
|
||||
|
||||
String collect(String videoId) throws IOException, InterruptedException {
|
||||
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
||||
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
||||
}
|
||||
Path dir = Path.of(downloadDir, "subtitles", videoId);
|
||||
Files.createDirectories(dir);
|
||||
Path output = dir.resolve("%(id)s.%(ext)s");
|
||||
List<String> command = buildCommand(ytdlpBin, videoId, output);
|
||||
|
||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||
StringBuilder outputLog = new StringBuilder();
|
||||
Thread drainer = new Thread(() -> drain(process, outputLog), "yt-dlp-subs-" + videoId);
|
||||
drainer.setDaemon(true);
|
||||
drainer.start();
|
||||
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
throw new RuntimeException("yt-dlp 자막 수집 타임아웃(" + timeoutSeconds + "s)");
|
||||
}
|
||||
drainer.join(2000);
|
||||
if (process.exitValue() != 0) {
|
||||
String logText = outputLog.toString();
|
||||
throw new RuntimeException("yt-dlp 자막 수집 실패: "
|
||||
+ logText.substring(Math.max(0, logText.length() - 500)).strip());
|
||||
}
|
||||
|
||||
List<Path> files;
|
||||
try (var stream = Files.list(dir)) {
|
||||
files = stream.filter(p -> p.getFileName().toString().endsWith(".vtt"))
|
||||
.sorted(Comparator.comparingInt(ShortformSubtitleService::subtitlePreference))
|
||||
.toList();
|
||||
}
|
||||
if (files.isEmpty()) return null;
|
||||
return Files.readString(files.get(0), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
static List<String> buildCommand(String binary, String videoId, Path output) {
|
||||
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
||||
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
||||
}
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(binary);
|
||||
command.add("--skip-download");
|
||||
command.add("--write-subs");
|
||||
command.add("--write-auto-subs");
|
||||
command.add("--sub-langs");
|
||||
command.add("ko,ko-orig");
|
||||
command.add("--sub-format");
|
||||
command.add("vtt");
|
||||
command.add("--output");
|
||||
command.add(output.toString());
|
||||
command.add("https://www.youtube.com/watch?v=" + videoId);
|
||||
return command;
|
||||
}
|
||||
|
||||
private static int subtitlePreference(Path path) {
|
||||
String name = path.getFileName().toString();
|
||||
if (name.endsWith(".ko.vtt")) return 0;
|
||||
if (name.endsWith(".ko-orig.vtt")) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static void drain(Process process, StringBuilder target) {
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) target.append(line).append('\n');
|
||||
} catch (IOException ignored) {
|
||||
// 프로세스 종료 시 스트림이 닫히는 것은 정상이다.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,187 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** YouTube 자막이 없는 숏폼 영상을 Whisper로 전사해 VTT를 생성한다. */
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ShortformWhisperService {
|
||||
|
||||
private static final Pattern VIDEO_ID = Pattern.compile("^[A-Za-z0-9_-]{11}$");
|
||||
|
||||
@Qualifier("pythonRestTemplate")
|
||||
private final RestTemplate pythonRestTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ShortformWhisperService(@Qualifier("pythonRestTemplate") RestTemplate pythonRestTemplate,
|
||||
ObjectMapper objectMapper) {
|
||||
this.pythonRestTemplate = pythonRestTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Value("${python.base-url:http://h-python.tolag.shop}")
|
||||
private String pythonBaseUrl;
|
||||
|
||||
@Value("${ytdlp.bin:yt-dlp}")
|
||||
private String ytdlpBin;
|
||||
|
||||
@Value("${ytdlp.timeout-seconds:600}")
|
||||
private long timeoutSeconds;
|
||||
|
||||
@Value("${download.dir:downloads}")
|
||||
private String downloadDir;
|
||||
|
||||
public String transcribe(String videoId) throws IOException, InterruptedException {
|
||||
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
||||
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
||||
}
|
||||
|
||||
Path dir = Path.of(downloadDir, "subtitles", videoId, "whisper");
|
||||
Files.createDirectories(dir);
|
||||
try {
|
||||
Path audio = downloadAudio(videoId, dir);
|
||||
ScriptResponseDto response = requestTranscription(audio);
|
||||
return toVtt(response == null ? null : response.getSegments());
|
||||
} finally {
|
||||
deleteFiles(dir);
|
||||
}
|
||||
}
|
||||
|
||||
private Path downloadAudio(String videoId, Path dir) throws IOException, InterruptedException {
|
||||
List<String> command = buildDownloadCommand(ytdlpBin, videoId, dir.resolve("source.%(ext)s"));
|
||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||
StringBuilder output = new StringBuilder();
|
||||
Thread drainer = new Thread(() -> drain(process, output), "yt-dlp-audio-" + videoId);
|
||||
drainer.setDaemon(true);
|
||||
drainer.start();
|
||||
|
||||
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
throw new RuntimeException("yt-dlp 오디오 다운로드 타임아웃(" + timeoutSeconds + "s)");
|
||||
}
|
||||
drainer.join(2000);
|
||||
if (process.exitValue() != 0) {
|
||||
String logText = output.toString();
|
||||
throw new RuntimeException("yt-dlp 오디오 다운로드 실패: "
|
||||
+ logText.substring(Math.max(0, logText.length() - 500)).strip());
|
||||
}
|
||||
|
||||
try (var files = Files.list(dir)) {
|
||||
return files.filter(Files::isRegularFile)
|
||||
.max(Comparator.comparingLong(ShortformWhisperService::fileSize))
|
||||
.orElseThrow(() -> new RuntimeException("다운로드된 오디오 파일이 없습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
private ScriptResponseDto requestTranscription(Path audio) throws IOException {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", new FileSystemResource(audio));
|
||||
|
||||
ResponseEntity<String> response = pythonRestTemplate.postForEntity(
|
||||
pythonBaseUrl + "/transcribe", new HttpEntity<>(body, headers), String.class);
|
||||
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||
throw new RuntimeException("Whisper API 실패: " + response.getStatusCode());
|
||||
}
|
||||
return objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
|
||||
}
|
||||
|
||||
static List<String> buildDownloadCommand(String binary, String videoId, Path output) {
|
||||
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
||||
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
||||
}
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(binary);
|
||||
command.add("--no-playlist");
|
||||
command.add("--force-overwrites");
|
||||
command.add("-f");
|
||||
command.add("bestaudio/best");
|
||||
command.add("-o");
|
||||
command.add(output.toString());
|
||||
command.add("https://www.youtube.com/watch?v=" + videoId);
|
||||
return command;
|
||||
}
|
||||
|
||||
static String toVtt(List<ScriptResponseDto.Segment> segments) {
|
||||
if (segments == null || segments.isEmpty()) return null;
|
||||
StringBuilder vtt = new StringBuilder("WEBVTT\n\n");
|
||||
int cue = 1;
|
||||
for (ScriptResponseDto.Segment segment : segments) {
|
||||
if (segment == null || segment.getText() == null || segment.getText().isBlank()
|
||||
|| segment.getEnd() <= segment.getStart()) continue;
|
||||
vtt.append(cue++).append('\n')
|
||||
.append(formatTimestamp(segment.getStart())).append(" --> ")
|
||||
.append(formatTimestamp(segment.getEnd())).append('\n')
|
||||
.append(segment.getText().strip()).append("\n\n");
|
||||
}
|
||||
return cue == 1 ? null : vtt.toString();
|
||||
}
|
||||
|
||||
static String formatTimestamp(double seconds) {
|
||||
long millis = Math.max(0, Math.round(seconds * 1000));
|
||||
long hours = millis / 3_600_000;
|
||||
long minutes = (millis % 3_600_000) / 60_000;
|
||||
long secs = (millis % 60_000) / 1000;
|
||||
long ms = millis % 1000;
|
||||
return "%02d:%02d:%02d.%03d".formatted(hours, minutes, secs, ms);
|
||||
}
|
||||
|
||||
private static long fileSize(Path path) {
|
||||
try {
|
||||
return Files.size(path);
|
||||
} catch (IOException ignored) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteFiles(Path dir) {
|
||||
try (var files = Files.list(dir)) {
|
||||
files.filter(Files::isRegularFile).forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Whisper 임시 파일 삭제 실패: {}", path, e);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
log.warn("Whisper 임시 폴더 정리 실패: {}", dir, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void drain(Process process, StringBuilder target) {
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) target.append(line).append('\n');
|
||||
} catch (IOException ignored) {
|
||||
// 프로세스 종료 중 스트림이 닫힐 수 있다.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
public enum SubtitleStatus {
|
||||
NOT_REQUESTED,
|
||||
COLLECTING,
|
||||
READY,
|
||||
UNAVAILABLE,
|
||||
FAILED
|
||||
}
|
||||
@ -2,7 +2,6 @@ package com.hlab.yanalyst.domain.shortform.dto;
|
||||
|
||||
import com.hlab.yanalyst.domain.shortform.ShortformClip;
|
||||
import com.hlab.yanalyst.domain.shortform.ShortformJob;
|
||||
import com.hlab.yanalyst.domain.shortform.ShortformCategory;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@ -11,19 +10,18 @@ public final class ShortformDtos {
|
||||
|
||||
private ShortformDtos() {}
|
||||
|
||||
public record RegisterRequest(String youtubeUrl, ShortformCategory category) {}
|
||||
public record RegisterRequest(String youtubeUrl) {}
|
||||
|
||||
public record ResultRequest(String rawText, String step1Text) {}
|
||||
|
||||
public record ImportRequest(String youtubeUrl, String rawText, String step1Text, ShortformCategory category) {}
|
||||
public record ImportRequest(String youtubeUrl, String rawText, String step1Text) {}
|
||||
|
||||
public record JobSummary(Long id, String youtubeUrl, String videoId, String title, String category,
|
||||
String status, String subtitleStatus, String subtitleError, int clipCount,
|
||||
public record JobSummary(Long id, String youtubeUrl, String videoId, String title,
|
||||
String status, int clipCount,
|
||||
LocalDateTime createdAt, LocalDateTime completedAt) {
|
||||
public static JobSummary from(ShortformJob job) {
|
||||
return new JobSummary(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
|
||||
job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(),
|
||||
job.subtitleStatusOrDefault().name(), job.getSubtitleError(), job.getClips().size(),
|
||||
job.getTitle(), job.getStatus().name(), job.getClips().size(),
|
||||
job.getCreatedAt(), job.getCompletedAt());
|
||||
}
|
||||
}
|
||||
@ -36,15 +34,13 @@ public final class ShortformDtos {
|
||||
}
|
||||
}
|
||||
|
||||
public record JobDetail(Long id, String youtubeUrl, String videoId, String title, String category,
|
||||
public record JobDetail(Long id, String youtubeUrl, String videoId, String title,
|
||||
String status, LocalDateTime createdAt, LocalDateTime completedAt,
|
||||
String rawOutput, String step1Output, String subtitleStatus,
|
||||
String subtitleVtt, String subtitleError, List<ClipDto> clips) {
|
||||
String rawOutput, String step1Output, List<ClipDto> clips) {
|
||||
public static JobDetail from(ShortformJob job) {
|
||||
return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
|
||||
job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(), job.getCreatedAt(),
|
||||
job.getTitle(), job.getStatus().name(), job.getCreatedAt(),
|
||||
job.getCompletedAt(), job.getRawOutput(), job.getStep1Output(),
|
||||
job.subtitleStatusOrDefault().name(), job.getSubtitleVtt(), job.getSubtitleError(),
|
||||
job.getClips().stream().map(ClipDto::from).toList());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package com.hlab.yanalyst.global.config;
|
||||
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
@ -29,13 +28,10 @@ public class RestTemplateConfig {
|
||||
* (주입 시 빈 이름 {@code pythonRestTemplate} 로 구분 — 다른 곳의 restTemplate 주입에는 영향 없음)
|
||||
*/
|
||||
@Bean
|
||||
public RestTemplate pythonRestTemplate(
|
||||
RestTemplateBuilder builder,
|
||||
@Value("${python.connect-timeout-seconds:20}") long connectTimeoutSeconds,
|
||||
@Value("${python.read-timeout-minutes:30}") long readTimeoutMinutes) {
|
||||
public RestTemplate pythonRestTemplate(RestTemplateBuilder builder) {
|
||||
return builder
|
||||
.connectTimeout(Duration.ofSeconds(connectTimeoutSeconds))
|
||||
.readTimeout(Duration.ofMinutes(readTimeoutMinutes))
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.readTimeout(Duration.ofMinutes(10))
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@ -61,16 +61,6 @@ public class WebController {
|
||||
@GetMapping("/feed")
|
||||
public String feed(Model model) {
|
||||
model.addAttribute("currentPage", "feed");
|
||||
model.addAttribute("feedTopic", "ENTERTAINMENT");
|
||||
model.addAttribute("feedTitle", "소재 피드");
|
||||
return "feed";
|
||||
}
|
||||
|
||||
@GetMapping("/politics-feed")
|
||||
public String politicsFeed(Model model) {
|
||||
model.addAttribute("currentPage", "politics-feed");
|
||||
model.addAttribute("feedTopic", "POLITICS");
|
||||
model.addAttribute("feedTitle", "정치 피드");
|
||||
return "feed";
|
||||
}
|
||||
|
||||
|
||||
@ -54,8 +54,6 @@ youtube:
|
||||
# Python 마이크로서비스(자막 /transcript, Whisper 전사 /transcribe). 동일 호스트면 http://localhost:8000 로 오버라이드.
|
||||
python:
|
||||
base-url: ${PYTHON_BASE_URL:http://h-python.tolag.shop}
|
||||
connect-timeout-seconds: ${PYTHON_CONNECT_TIMEOUT_SECONDS:20}
|
||||
read-timeout-minutes: ${PYTHON_READ_TIMEOUT_MINUTES:30} # 긴 영상 Whisper 전사·렌더 응답 대기
|
||||
|
||||
# yt-dlp 원본 다운로드(로컬 ProcessBuilder 실행). 재가공 '원본 다운로드' 버튼이 사용.
|
||||
ytdlp:
|
||||
@ -64,7 +62,7 @@ ytdlp:
|
||||
max-height: ${YTDLP_MAX_HEIGHT:1080} # bv*[height<=N]+ba/b, mp4 머지
|
||||
timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600}
|
||||
download:
|
||||
dir: ${DOWNLOAD_DIR:downloads} # 원본 캐시 + 정치 큐 자막(subtitles/) 저장 폴더
|
||||
dir: ${DOWNLOAD_DIR:downloads} # 다운로드 캐시 폴더(작업 디렉토리 기준 상대경로 가능)
|
||||
|
||||
# 자가호스팅 LibreTranslate(원본 스크립트 번역 → 재가공 초안). POST /translate {q,source,target}.
|
||||
translate:
|
||||
@ -102,12 +100,11 @@ hlab:
|
||||
# 발굴 대상 포맷 기본값: LONG_FORM(롱폼) | SHORTS. UI '지금 발굴'에서 건별 선택 가능.
|
||||
format: ${DISCOVERY_FORMAT:LONG_FORM}
|
||||
|
||||
# 소재 발굴 피드: 소스(웹예능 공식채널)·경쟁(클립 채널)의 신규 롱폼만 최신순으로 수집 (10분 미만 제외)
|
||||
# 소재 발굴 피드: 소스(웹예능 공식채널) 신규 롱폼 + 경쟁(예능짤) 신규 쇼츠를 최신순으로 수집
|
||||
feed:
|
||||
enabled: ${FEED_ENABLED:true}
|
||||
cron: ${FEED_CRON:0 15 * * * *} # 1시간마다 (소재 선점은 업로드 후 몇 시간이 승부 — 채널당 2 units라 부담 없음)
|
||||
cron: ${FEED_CRON:0 15 */3 * * *} # 3시간마다 (소재 선점은 업로드 후 몇 시간이 승부)
|
||||
period-days: ${FEED_PERIOD_DAYS:14} # 이보다 오래된 업로드는 수집하지 않음
|
||||
min-duration-sec: ${FEED_MIN_DURATION_SEC:600} # 이보다 짧은 영상은 수집·표시하지 않음 (기본 10분)
|
||||
seed:
|
||||
max-keywords: ${FEED_SEED_MAX_KEYWORDS:8} # 시드 자동 발굴 시 사용할 상위 해시태그 수(1개당 100 units)
|
||||
min-tag-count: ${FEED_SEED_MIN_TAG_COUNT:2} # 이 횟수 미만 해시태그는 프로그램명으로 보지 않음
|
||||
@ -121,10 +118,9 @@ hlab:
|
||||
# 생 조회수로 자르면 방금 올라온 영상까지 걸리므로 시간당 조회수로 거른다.
|
||||
min-views-per-hour: ${FEED_PERSON_MIN_VPH:20}
|
||||
|
||||
# 텔레그램 알림: 아침 추천 다이제스트 + 피드 골든타임 소재(신규 수집분이 업로드 24h 이내일 때).
|
||||
# 토큰/챗ID 없으면 자동 no-op.
|
||||
# 텔레그램 아침 추천: 발굴 직후 상위 추천채널 다이제스트를 발송. 토큰/챗ID 없으면 자동 no-op.
|
||||
notify:
|
||||
telegram:
|
||||
bot-token: ${TELEGRAM_BOT_TOKEN:8996580856:AAH3DhjiAZikcvyZP10I7fEXFb1spFhr7Hw}
|
||||
chat-id: ${TELEGRAM_CHAT_ID:1864593285}
|
||||
bot-token: ${TELEGRAM_BOT_TOKEN:}
|
||||
chat-id: ${TELEGRAM_CHAT_ID:}
|
||||
morning-digest-top-n: ${TELEGRAM_DIGEST_TOP_N:5}
|
||||
|
||||
@ -3,19 +3,18 @@
|
||||
layout:decorate="~{layout/base}">
|
||||
|
||||
<head>
|
||||
<title th:text="'h-lab - ' + ${feedTitle}">h-lab - 소재 피드</title>
|
||||
<title>h-lab - 소재 피드</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 th:text="${feedTitle}">소재 피드</h1>
|
||||
<p class="sub" th:if="${feedTopic == 'ENTERTAINMENT'}">소재 원본(웹예능)과 경쟁 채널의 신규 롱폼 업로드를 최신순으로 봅니다.</p>
|
||||
<p class="sub" th:if="${feedTopic == 'POLITICS'}">공식 야당 정치인과 정치 원본 채널의 일반 영상을 최신순으로 수집합니다.</p>
|
||||
<h1>소재 피드</h1>
|
||||
<p class="sub">소재 원본(웹예능 롱폼)과 경쟁 쇼츠의 신규 업로드를 최신순으로 봅니다.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" onclick="openPersons()" th:if="${feedTopic == 'ENTERTAINMENT'}">
|
||||
<button class="btn btn-secondary" onclick="openPersons()">
|
||||
<i data-lucide="user-search" style="width:15px;"></i> 인물 관리
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="openSeeds()">
|
||||
@ -32,16 +31,16 @@
|
||||
<button class="feed-tab" role="tab" id="tab-SOURCE" data-tab="SOURCE"
|
||||
aria-selected="true" aria-controls="feedGrid" onclick="switchTab('SOURCE')">
|
||||
<i data-lucide="film" style="width:15px;"></i>
|
||||
<span th:text="${feedTopic == 'POLITICS'} ? '정치 원본' : '소재 원본'">소재 원본</span>
|
||||
<span class="feed-tab-hint" th:text="${feedTopic == 'POLITICS'} ? '공식 정치 채널' : '웹예능 롱폼'">웹예능 롱폼</span>
|
||||
<span>소재 원본</span>
|
||||
<span class="feed-tab-hint">웹예능 롱폼</span>
|
||||
</button>
|
||||
<button class="feed-tab" role="tab" id="tab-RIVAL" data-tab="RIVAL"
|
||||
aria-selected="false" aria-controls="feedGrid" tabindex="-1" onclick="switchTab('RIVAL')">
|
||||
<i data-lucide="swords" style="width:15px;"></i>
|
||||
<span>경쟁 채널</span>
|
||||
<span class="feed-tab-hint" th:text="${feedTopic == 'POLITICS'} ? '정치 쇼츠 참고' : '예능짤 채널'">예능짤 채널</span>
|
||||
<span>경쟁 쇼츠</span>
|
||||
<span class="feed-tab-hint">예능짤 채널</span>
|
||||
</button>
|
||||
<button class="feed-tab" role="tab" id="tab-PERSON" data-tab="PERSON" th:if="${feedTopic == 'ENTERTAINMENT'}"
|
||||
<button class="feed-tab" role="tab" id="tab-PERSON" data-tab="PERSON"
|
||||
aria-selected="false" aria-controls="feedGrid" tabindex="-1" onclick="switchTab('PERSON')">
|
||||
<i data-lucide="user-search" style="width:15px;"></i>
|
||||
<span>인물</span>
|
||||
@ -65,7 +64,7 @@
|
||||
<label class="field" for="fLength" id="lengthField">길이
|
||||
<select id="fLength" onchange="applyFilters()">
|
||||
<option value="">전체</option>
|
||||
<option value="CLIP">공식클립 (10~15분)</option>
|
||||
<option value="CLIP">공식클립 (~15분)</option>
|
||||
<option value="FULL">풀에피 (15분+)</option>
|
||||
</select>
|
||||
</label>
|
||||
@ -102,8 +101,8 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-sm text-muted mb-3" style="line-height:1.6;">
|
||||
<span th:if="${feedTopic == 'ENTERTAINMENT'}"><b>소재 원본</b>은 웹예능 공식 채널, <b>경쟁 채널</b>은 같은 소재를 다루는 예능 채널입니다.</span>
|
||||
<span th:if="${feedTopic == 'POLITICS'}"><b>정치 원본</b>에는 공식 정당·정치인 채널을 등록하세요. 팬 채널과 재업로드 채널은 제외하는 것이 좋습니다.</span>
|
||||
<b>소재 원본</b>은 유퀴즈·핑계고 같은 웹예능 공식채널(롱폼만 수집),
|
||||
<b>경쟁 쇼츠</b>는 같은 소재를 다루는 예능짤 채널(쇼츠만 수집)입니다.
|
||||
</p>
|
||||
|
||||
<div class="seed-add">
|
||||
@ -112,14 +111,7 @@
|
||||
<label class="sr-only" for="seedRole">역할</label>
|
||||
<select id="seedRole">
|
||||
<option value="SOURCE">소재 원본</option>
|
||||
<option value="RIVAL">경쟁 채널</option>
|
||||
</select>
|
||||
<label class="sr-only" for="seedFormat">수집 형식</label>
|
||||
<select id="seedFormat">
|
||||
<option value="LONG_FORM">롱폼 (10분+)</option>
|
||||
<option value="VIDEO" th:selected="${feedTopic == 'POLITICS'}">일반 영상 (66초+)</option>
|
||||
<option value="SHORTS">쇼츠만</option>
|
||||
<option value="ALL">전체</option>
|
||||
<option value="RIVAL">경쟁 쇼츠</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" id="seedAddBtn" onclick="addSeed()">추가</button>
|
||||
</div>
|
||||
@ -359,8 +351,6 @@
|
||||
const API = '/api/feed';
|
||||
const CV_API = '/api/v1/channel-videos';
|
||||
const SF_API = '/api/shortform';
|
||||
const FEED_TOPIC = /*[[${feedTopic}]]*/ 'ENTERTAINMENT';
|
||||
const SHORTFORM_CATEGORY = FEED_TOPIC;
|
||||
|
||||
let currentTab = 'SOURCE';
|
||||
let items = [];
|
||||
@ -436,9 +426,9 @@
|
||||
}
|
||||
|
||||
// ---------- 탭 ----------
|
||||
const TABS = FEED_TOPIC === 'POLITICS' ? ['SOURCE', 'RIVAL'] : ['SOURCE', 'RIVAL', 'PERSON'];
|
||||
const TABS = ['SOURCE', 'RIVAL', 'PERSON'];
|
||||
|
||||
// 탭별로 의미 있는 필터만 남긴다 (소스·경쟁은 롱폼 채널 기준, 인물은 채널이 아니라 사람 기준)
|
||||
// 탭별로 의미 있는 필터만 남긴다 (경쟁은 전부 쇼츠, 인물은 채널이 아니라 사람 기준)
|
||||
function applyTabChrome(tab){
|
||||
document.querySelectorAll('.feed-tab').forEach(btn => {
|
||||
const on = btn.dataset.tab === tab;
|
||||
@ -446,10 +436,10 @@
|
||||
btn.tabIndex = on ? 0 : -1;
|
||||
});
|
||||
document.getElementById('feedGrid').setAttribute('aria-labelledby', 'tab-' + tab);
|
||||
document.getElementById('lengthField').style.display = (tab === 'PERSON') ? 'none' : '';
|
||||
document.getElementById('lengthField').style.display = (tab === 'SOURCE') ? '' : 'none';
|
||||
document.getElementById('programField').style.display = (tab === 'PERSON') ? 'none' : '';
|
||||
document.getElementById('personField').style.display = (tab === 'PERSON') ? '' : 'none';
|
||||
if(tab === 'PERSON') document.getElementById('fLength').value = '';
|
||||
if(tab !== 'SOURCE') document.getElementById('fLength').value = '';
|
||||
}
|
||||
|
||||
function switchTab(tab){
|
||||
@ -519,7 +509,7 @@
|
||||
const sel = document.getElementById('fProgram');
|
||||
const keep = preselect || sel.value;
|
||||
let list = [];
|
||||
try { list = await api(`${API}/programs?tab=${currentTab}&topic=${FEED_TOPIC}`) || []; } catch(e){ list = []; }
|
||||
try { list = await api(`${API}/programs?tab=${currentTab}`) || []; } catch(e){ list = []; }
|
||||
sel.innerHTML = '<option value="">전체</option>' + list.map(p =>
|
||||
`<option value="${esc(p.ytChannelId)}">${esc(p.channelTitle||'(이름 없음)')} (${p.videoCount})</option>`).join('');
|
||||
if(keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
|
||||
@ -541,7 +531,6 @@
|
||||
const f = readFilters();
|
||||
const p = new URLSearchParams();
|
||||
p.set('tab', f.tab);
|
||||
p.set('topic', FEED_TOPIC);
|
||||
if(f.tab === 'PERSON'){ if(f.person) p.set('person', f.person); }
|
||||
else if(f.channelId) p.set('channelId', f.channelId);
|
||||
if(f.lengthBucket) p.set('lengthBucket', f.lengthBucket);
|
||||
@ -583,13 +572,13 @@
|
||||
? `<span>·</span><span class="num">${fmtNum(Math.round(it.viewsPerHour))}/h</span>` : '';
|
||||
|
||||
// 롱폼 소재(소스·인물)는 숏폼 큐로 넘기는 게 주 동선이다.
|
||||
// 경쟁 채널 영상은 경쟁자의 결과물(벤치마크)이라 큐 대상이 아니고 원본 보기만 준다.
|
||||
// 경쟁 쇼츠는 이미 잘린 결과물이라 큐 대상이 아니고 원본 보기만 준다.
|
||||
const inQueue = queued.has(it.videoId);
|
||||
const primary = currentTab === 'RIVAL'
|
||||
? `<a class="btn btn-secondary grow" href="${url}" target="_blank" rel="noopener">
|
||||
<i data-lucide="external-link" style="width:14px;"></i> 원본 보기</a>`
|
||||
: (inQueue
|
||||
? `<a class="btn btn-secondary grow" href="/shortform?category=${SHORTFORM_CATEGORY}">
|
||||
? `<a class="btn btn-secondary grow" href="/shortform">
|
||||
<i data-lucide="check" style="width:14px;"></i> 큐에 있음</a>`
|
||||
: `<button class="btn btn-primary grow" id="sfBtn-${it.id}"
|
||||
onclick="sendToQueue(${it.id})">
|
||||
@ -629,11 +618,6 @@
|
||||
aria-label="재가공 스튜디오에서 열기">
|
||||
<i data-lucide="wand-2" style="width:16px;"></i>
|
||||
</a>`}
|
||||
${FEED_TOPIC === 'POLITICS' ? `
|
||||
<button class="icon-btn" title="영상 URL 복사" aria-label="영상 URL 복사"
|
||||
onclick="copyVideoUrl('${esc(it.videoId)}')">
|
||||
<i data-lucide="copy" style="width:16px;"></i>
|
||||
</button>` : ''}
|
||||
<button class="icon-btn${it.bookmarked ? ' on' : ''}" title="북마크"
|
||||
aria-label="북마크" aria-pressed="${it.bookmarked}"
|
||||
onclick="toggleBookmark(${it.id}, ${!!it.bookmarked})">
|
||||
@ -661,7 +645,7 @@
|
||||
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
|
||||
} else {
|
||||
showState('inbox', '아직 소재가 없습니다',
|
||||
'경쟁 예능짤 채널을 시드로 등록한 뒤 수집하세요. (10분 이상만 수집됩니다)',
|
||||
'경쟁 예능짤 쇼츠 채널을 시드로 등록한 뒤 수집하세요.',
|
||||
`<button class="btn btn-primary" onclick="openSeeds()">시드 등록하기</button>
|
||||
<button class="btn btn-secondary" onclick="runCollect()">지금 수집</button>`);
|
||||
}
|
||||
@ -704,7 +688,7 @@
|
||||
// 임베드가 막힌 영상이 있어 YouTube 로 여는 길을 항상 열어둔다
|
||||
const inQueue = queued.has(it.videoId);
|
||||
const queueBtn = currentTab === 'RIVAL' ? '' : (inQueue
|
||||
? `<a class="btn btn-secondary" href="/shortform?category=${SHORTFORM_CATEGORY}">
|
||||
? `<a class="btn btn-secondary" href="/shortform">
|
||||
<i data-lucide="check" style="width:14px;"></i> 큐에 있음</a>`
|
||||
: `<button class="btn btn-primary" onclick="sendToQueue(${it.id}); closeVideo();">
|
||||
<i data-lucide="clapperboard" style="width:14px;"></i> 숏폼 큐로</button>`);
|
||||
@ -731,7 +715,7 @@
|
||||
/** 큐에 이미 담긴 videoId 를 받아둔다. 실패해도 피드 자체는 보여야 하므로 조용히 넘긴다. */
|
||||
async function loadQueued(){
|
||||
try {
|
||||
const jobs = await api(SF_API + '/jobs?category=' + SHORTFORM_CATEGORY) || [];
|
||||
const jobs = await api(SF_API + '/jobs') || [];
|
||||
queued = new Set(jobs.map(j => j.videoId).filter(Boolean));
|
||||
} catch(e){ /* 큐를 못 읽어도 피드는 정상 동작 */ }
|
||||
}
|
||||
@ -748,8 +732,7 @@
|
||||
try {
|
||||
await api(SF_API + '/jobs', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ youtubeUrl: 'https://www.youtube.com/watch?v=' + it.videoId,
|
||||
category: SHORTFORM_CATEGORY })
|
||||
body: JSON.stringify({ youtubeUrl: 'https://www.youtube.com/watch?v=' + it.videoId })
|
||||
});
|
||||
queued.add(it.videoId);
|
||||
render();
|
||||
@ -760,25 +743,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function copyVideoUrl(videoId){
|
||||
const url = 'https://www.youtube.com/watch?v=' + videoId;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast('영상 URL을 복사했습니다');
|
||||
} catch(e) {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = url;
|
||||
input.setAttribute('readonly', '');
|
||||
input.style.position = 'fixed';
|
||||
input.style.opacity = '0';
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
const copied = document.execCommand('copy');
|
||||
input.remove();
|
||||
toast(copied ? '영상 URL을 복사했습니다' : 'URL 복사에 실패했습니다', !copied);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 카드 액션 ----------
|
||||
async function toggleBookmark(id, current){
|
||||
try {
|
||||
@ -813,7 +777,7 @@
|
||||
btn.innerHTML = '<i data-lucide="loader-2" style="width:15px;" class="animate-spin"></i> 수집 중...';
|
||||
if(window.lucide) lucide.createIcons();
|
||||
try {
|
||||
const r = await api(API + '/collect?topic=' + FEED_TOPIC, { method:'POST' });
|
||||
const r = await api(API + '/collect', { method:'POST' });
|
||||
toast(`수집 완료 — 시드 ${r.seeds}개 중 ${r.collected}개, 영상 ${r.savedVideos}건`);
|
||||
await loadPrograms();
|
||||
await loadFeed();
|
||||
@ -838,7 +802,7 @@
|
||||
const box = document.getElementById('seedList');
|
||||
box.innerHTML = '<div class="text-sm text-muted p-4">로딩 중...</div>';
|
||||
let list = [];
|
||||
try { list = await api(API + '/seeds?topic=' + FEED_TOPIC) || []; }
|
||||
try { list = await api(API + '/seeds') || []; }
|
||||
catch(e){ box.innerHTML = `<div class="text-sm text-danger p-4">불러오기 실패: ${esc(e.message)}</div>`; return; }
|
||||
|
||||
if(list.length === 0){
|
||||
@ -846,8 +810,7 @@
|
||||
return;
|
||||
}
|
||||
box.innerHTML = list.map(s => {
|
||||
const roleLabel = s.role === 'SOURCE'
|
||||
? (FEED_TOPIC === 'POLITICS' ? '정치 원본' : '소재 원본') : '경쟁 채널';
|
||||
const roleLabel = s.role === 'SOURCE' ? '소재 원본' : '경쟁 쇼츠';
|
||||
const roleCls = s.role === 'SOURCE' ? 'badge-primary' : 'badge-muted';
|
||||
const warn = s.disabled
|
||||
? `<button class="warn" title="수집 실패 ${s.failCount}회로 중단됨 — 다시 활성화"
|
||||
@ -858,7 +821,7 @@
|
||||
<img src="${esc(s.thumbnailUrl)}" alt="" loading="lazy">
|
||||
<div class="sname">
|
||||
<b>${esc(s.title)}</b>
|
||||
<span>구독 ${fmtNum(s.subscriberCount)} · ${s.format}${s.disabled ? ' · 수집 중단됨' : ''}</span>
|
||||
<span>구독 ${fmtNum(s.subscriberCount)}${s.disabled ? ' · 수집 중단됨' : ''}</span>
|
||||
</div>
|
||||
<span class="badge ${roleCls}">${roleLabel}</span>
|
||||
${warn}
|
||||
@ -885,10 +848,9 @@
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const role = document.getElementById('seedRole').value;
|
||||
const format = document.getElementById('seedFormat').value;
|
||||
const s = await api(API + '/seeds', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ url, role, topic: FEED_TOPIC, format })
|
||||
body: JSON.stringify({ url, role })
|
||||
});
|
||||
document.getElementById('seedUrl').value = '';
|
||||
toast(`${s.title} 을(를) 시드로 등록했습니다`);
|
||||
|
||||
@ -24,9 +24,6 @@
|
||||
<a th:href="@{/feed}" class="nav-item" th:classappend="${currentPage == 'feed'} ? 'active'">
|
||||
<i data-lucide="rss" class="nav-icon"></i><span class="nav-text">소재 피드</span>
|
||||
</a>
|
||||
<a th:href="@{/politics-feed}" class="nav-item" th:classappend="${currentPage == 'politics-feed'} ? 'active'">
|
||||
<i data-lucide="landmark" class="nav-icon"></i><span class="nav-text">정치 피드</span>
|
||||
</a>
|
||||
<a th:href="@{/shortform}" class="nav-item" th:classappend="${currentPage == 'shortform'} ? 'active'">
|
||||
<i data-lucide="clapperboard" class="nav-icon"></i><span class="nav-text">숏폼 큐</span>
|
||||
</a>
|
||||
|
||||
@ -7,12 +7,6 @@
|
||||
<style>
|
||||
.sf-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.sf-form input { flex: 1; }
|
||||
.sf-tabs { display:flex; gap:.35rem; border-bottom:1px solid var(--border); margin-bottom:1rem; }
|
||||
.sf-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--text-2); padding:.7rem 1rem; font:600 .9rem/1 inherit; cursor:pointer; }
|
||||
.sf-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
|
||||
.sf-tab:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; border-radius:var(--r-sm); }
|
||||
.sf-category { font-size:.68rem; padding:.18rem .45rem; border-radius:99px; background:var(--surface-2); color:var(--text-2); font-weight:700; }
|
||||
.sf-category.POLITICS { background:rgba(68, 94, 160, .12); color:#445ea0; }
|
||||
.sf-paste { display: none; margin-bottom: 1rem; }
|
||||
.sf-paste.open { display: block; }
|
||||
.sf-paste textarea { width: 100%; min-height: 160px; font-family: monospace; font-size: 0.8rem; }
|
||||
@ -26,9 +20,6 @@
|
||||
.sf-badge.PENDING { background: rgba(196, 122, 18, 0.15); color: var(--warning); }
|
||||
.sf-badge.DONE { background: rgba(var(--success-rgb), 0.15); color: var(--success); }
|
||||
.sf-badge.FAILED { background: rgba(var(--danger-rgb), 0.15); color: var(--danger); }
|
||||
.sf-subtitle { font-size:.7rem; color:var(--text-2); white-space:nowrap; }
|
||||
.sf-subtitle.READY { color:var(--success); }
|
||||
.sf-subtitle.FAILED, .sf-subtitle.UNAVAILABLE { color:var(--danger); }
|
||||
.sf-clips { display: none; padding: 0.75rem 0.9rem; border-top: 1px solid var(--border); }
|
||||
.sf-job.open .sf-clips { display: block; }
|
||||
.sf-clip { border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 0.6rem; }
|
||||
@ -57,13 +48,6 @@
|
||||
<button class="btn btn-primary" type="submit">큐에 등록</button>
|
||||
</form>
|
||||
|
||||
<div class="sf-tabs" role="tablist" aria-label="콘텐츠 분류">
|
||||
<button class="sf-tab active" id="sfTab-ENTERTAINMENT" role="tab" aria-selected="true"
|
||||
onclick="switchCategory('ENTERTAINMENT')">예능</button>
|
||||
<button class="sf-tab" id="sfTab-POLITICS" role="tab" aria-selected="false"
|
||||
onclick="switchCategory('POLITICS')">정치</button>
|
||||
</div>
|
||||
|
||||
<div class="sf-paste" id="pasteBox">
|
||||
<input type="text" id="pasteUrl" placeholder="영상 URL" style="margin-bottom:0.5rem;width:100%;">
|
||||
<textarea id="pasteRaw" placeholder="Opal Output 전체를 붙여넣으세요 (1===== ... 5===== 전부)"></textarea>
|
||||
@ -75,11 +59,6 @@
|
||||
|
||||
<th:block layout:fragment="script">
|
||||
<script>
|
||||
let currentCategory = new URLSearchParams(location.search).get('category') === 'POLITICS'
|
||||
? 'POLITICS' : 'ENTERTAINMENT';
|
||||
let subtitlePollTimer = null;
|
||||
const openJobIds = new Set();
|
||||
|
||||
async function api(path, opts) {
|
||||
const res = await fetch(path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts));
|
||||
const body = await res.json();
|
||||
@ -91,25 +70,11 @@
|
||||
document.getElementById('pasteBox').classList.toggle('open');
|
||||
}
|
||||
|
||||
function switchCategory(category) {
|
||||
currentCategory = category;
|
||||
document.querySelectorAll('.sf-tab').forEach(tab => {
|
||||
const active = tab.id === 'sfTab-' + category;
|
||||
tab.classList.toggle('active', active);
|
||||
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
const q = new URLSearchParams(location.search);
|
||||
q.set('category', category);
|
||||
history.replaceState(null, '', location.pathname + '?' + q.toString());
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
async function registerJob(e) {
|
||||
e.preventDefault();
|
||||
const youtubeUrl = document.getElementById('urlInput').value.trim();
|
||||
try {
|
||||
await api('/api/shortform/jobs', { method: 'POST',
|
||||
body: JSON.stringify({ youtubeUrl, category: currentCategory }) });
|
||||
await api('/api/shortform/jobs', { method: 'POST', body: JSON.stringify({ youtubeUrl }) });
|
||||
document.getElementById('urlInput').value = '';
|
||||
loadJobs();
|
||||
} catch (err) { alert(err.message); }
|
||||
@ -120,8 +85,7 @@
|
||||
const rawText = document.getElementById('pasteRaw').value;
|
||||
if (!youtubeUrl || !rawText.trim()) { alert('URL과 결과 원문을 모두 입력하세요'); return; }
|
||||
try {
|
||||
await api('/api/shortform/import', { method: 'POST',
|
||||
body: JSON.stringify({ youtubeUrl, rawText, category: currentCategory }) });
|
||||
await api('/api/shortform/import', { method: 'POST', body: JSON.stringify({ youtubeUrl, rawText }) });
|
||||
document.getElementById('pasteUrl').value = '';
|
||||
document.getElementById('pasteRaw').value = '';
|
||||
togglePaste();
|
||||
@ -133,29 +97,11 @@
|
||||
e.stopPropagation();
|
||||
if (!confirm('이 작업을 삭제할까요?')) return;
|
||||
await api('/api/shortform/jobs/' + id, { method: 'DELETE' });
|
||||
openJobIds.delete(id);
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
// 결과만 지우고 대기로 되돌린다. 등록 URL은 남으므로 큐를 다시 돌리면 그대로 재실행된다.
|
||||
async function resetJob(id, e) {
|
||||
e.stopPropagation();
|
||||
if (!confirm('결과를 지우고 다시 대기 상태로 되돌릴까요?\n'
|
||||
+ '클립과 Output 원문이 삭제됩니다. 등록된 영상은 그대로 남습니다.')) return;
|
||||
try {
|
||||
await api('/api/shortform/jobs/' + id + '/reset', { method: 'POST' });
|
||||
loadJobs();
|
||||
} catch (err) { alert('되돌리기 실패: ' + err.message); }
|
||||
}
|
||||
|
||||
async function toggleJob(id, el, restoreOpen = false) {
|
||||
if (el.classList.contains('open')) {
|
||||
if (!restoreOpen) {
|
||||
el.classList.remove('open');
|
||||
openJobIds.delete(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
async function toggleJob(id, el) {
|
||||
if (el.classList.contains('open')) { el.classList.remove('open'); return; }
|
||||
const detail = await api('/api/shortform/jobs/' + id);
|
||||
const toolbar = `<div class="sf-clip-head" style="margin-bottom:0.6rem;">
|
||||
<button class="btn btn-primary" data-copy="${encodeURIComponent(detail.rawOutput || '')}"
|
||||
@ -166,10 +112,6 @@
|
||||
${detail.step1Output ? '' : 'disabled'}>Step 1 후보구간 복사</button>
|
||||
<button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.youtubeUrl || '')}"
|
||||
data-label="영상 URL 복사">영상 URL 복사</button>
|
||||
<button class="btn btn-secondary" data-subtitle-copy
|
||||
data-label="타임스탬프 자막 복사" ${detail.subtitleVtt ? '' : 'disabled'}>타임스탬프 자막 복사</button>
|
||||
${detail.subtitleStatus === 'READY' || detail.subtitleStatus === 'COLLECTING' ? '' : `
|
||||
<button class="btn btn-secondary" onclick="retrySubtitles(${detail.id}, event)">자막 다시 수집</button>`}
|
||||
</div>`;
|
||||
const step1Block = detail.step1Output
|
||||
? `<details class="sf-clip" style="margin-bottom:0.6rem;">
|
||||
@ -181,7 +123,6 @@
|
||||
? detail.clips.map(clipHtml).join('')
|
||||
: '<p style="color:var(--text-3);">저장된 클립이 없습니다. 결과 붙여넣기 또는 "숏폼 큐 돌려줘"로 채우세요.</p>');
|
||||
el.classList.add('open');
|
||||
openJobIds.add(id);
|
||||
el.querySelectorAll('[data-copy]').forEach(btn => {
|
||||
const label = btn.dataset.label || 'capcut2 JSON 복사';
|
||||
btn.onclick = (e) => {
|
||||
@ -191,15 +132,6 @@
|
||||
setTimeout(() => btn.textContent = label, 1200);
|
||||
};
|
||||
});
|
||||
const subtitleButton = el.querySelector('[data-subtitle-copy]');
|
||||
if (subtitleButton && detail.subtitleVtt) {
|
||||
subtitleButton.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(detail.subtitleVtt);
|
||||
subtitleButton.textContent = '복사됨!';
|
||||
setTimeout(() => subtitleButton.textContent = '타임스탬프 자막 복사', 1200);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
@ -220,100 +152,31 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function retrySubtitles(id, e) {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api('/api/shortform/jobs/' + id + '/subtitles/retry', { method: 'POST' });
|
||||
loadJobs();
|
||||
} catch (err) { alert('자막 수집 요청 실패: ' + err.message); }
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
const jobs = await api('/api/shortform/jobs?category=' + currentCategory);
|
||||
const jobs = await api('/api/shortform/jobs');
|
||||
const list = document.getElementById('jobList');
|
||||
list.innerHTML = jobs.length ? '' : '<p style="color:var(--text-3);">등록된 작업이 없습니다.</p>';
|
||||
jobs.forEach(j => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sf-job';
|
||||
el.dataset.jobId = j.id;
|
||||
el.innerHTML = `<div class="sf-job-head">
|
||||
<img src="https://img.youtube.com/vi/${j.videoId}/mqdefault.jpg" alt="">
|
||||
<div class="sf-job-title">
|
||||
<div class="t">${esc(j.title || j.videoId)}</div>
|
||||
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
|
||||
<div class="sf-subtitle ${j.subtitleStatus}" data-subtitle-status="${j.subtitleStatus}"
|
||||
title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j)}</div>
|
||||
</div>
|
||||
<span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span>
|
||||
<span class="sf-badge ${j.status}">${j.status}</span>
|
||||
${j.status === 'PENDING' ? '' : `
|
||||
<button class="btn btn-secondary" onclick="resetJob(${j.id}, event)"
|
||||
title="결과를 지우고 다시 대기로 — 큐를 돌리면 재실행됩니다">
|
||||
<i data-lucide="rotate-ccw" style="width:14px;"></i></button>`}
|
||||
<button class="btn btn-secondary" onclick="deleteJob(${j.id}, event)"
|
||||
title="작업 삭제">
|
||||
<button class="btn btn-secondary" onclick="deleteJob(${j.id}, event)">
|
||||
<i data-lucide="trash-2" style="width:14px;"></i></button>
|
||||
</div>
|
||||
<div class="sf-clips"></div>`;
|
||||
el.querySelector('.sf-job-head').onclick = () => toggleJob(j.id, el);
|
||||
list.appendChild(el);
|
||||
if (openJobIds.has(j.id)) {
|
||||
toggleJob(j.id, el, true).catch(() => openJobIds.delete(j.id));
|
||||
}
|
||||
});
|
||||
if (window.lucide) lucide.createIcons();
|
||||
scheduleSubtitlePoll(jobs);
|
||||
}
|
||||
|
||||
function scheduleSubtitlePoll(jobs) {
|
||||
clearTimeout(subtitlePollTimer);
|
||||
if (jobs.some(j => j.subtitleStatus === 'COLLECTING')) {
|
||||
subtitlePollTimer = setTimeout(pollSubtitleStatuses, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSubtitleStatuses() {
|
||||
try {
|
||||
const jobs = await api('/api/shortform/jobs?category=' + currentCategory);
|
||||
jobs.forEach(j => {
|
||||
const card = document.querySelector(`.sf-job[data-job-id="${j.id}"]`);
|
||||
const status = card && card.querySelector('[data-subtitle-status]');
|
||||
if (!status) return;
|
||||
const previous = status.dataset.subtitleStatus;
|
||||
status.dataset.subtitleStatus = j.subtitleStatus;
|
||||
status.className = 'sf-subtitle ' + j.subtitleStatus;
|
||||
status.title = j.subtitleError || '';
|
||||
status.textContent = '자막 ' + subtitleLabel(j);
|
||||
if (previous === 'COLLECTING' && j.subtitleStatus === 'READY' && card.classList.contains('open')) {
|
||||
refreshSubtitleCopy(j.id, card);
|
||||
}
|
||||
});
|
||||
scheduleSubtitlePoll(jobs);
|
||||
} catch (err) {
|
||||
subtitlePollTimer = setTimeout(pollSubtitleStatuses, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSubtitleCopy(id, card) {
|
||||
const detail = await api('/api/shortform/jobs/' + id);
|
||||
const button = card.querySelector('[data-subtitle-copy]');
|
||||
if (!button || !detail.subtitleVtt) return;
|
||||
button.disabled = false;
|
||||
button.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(detail.subtitleVtt);
|
||||
button.textContent = '복사됨!';
|
||||
setTimeout(() => button.textContent = '타임스탬프 자막 복사', 1200);
|
||||
};
|
||||
}
|
||||
|
||||
function subtitleLabel(job) {
|
||||
if (job.subtitleStatus === 'COLLECTING' && job.subtitleError) return job.subtitleError;
|
||||
return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료',
|
||||
UNAVAILABLE:'없음', FAILED:'실패'})[job.subtitleStatus] || '대기';
|
||||
}
|
||||
|
||||
switchCategory(currentCategory);
|
||||
loadJobs();
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
@ -28,6 +28,12 @@ class ChannelRoleTest {
|
||||
assertThat(ChannelRole.isFeed(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsShorts_경쟁채널만_쇼츠_소스채널은_롱폼() {
|
||||
assertThat(ChannelRole.acceptsShorts(ChannelRole.RIVAL)).isTrue();
|
||||
assertThat(ChannelRole.acceptsShorts(ChannelRole.SOURCE)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void OWN은_별도_역할이며_isFeed가_아니다() {
|
||||
assertThat(ChannelRole.normalize("own")).isEqualTo(ChannelRole.OWN);
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.channel;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FeedClassificationTest {
|
||||
|
||||
@Test
|
||||
void legacyValuesDefaultToEntertainmentLongForm() {
|
||||
Channel channel = Channel.builder().channelId("UC-test").title("test").build();
|
||||
|
||||
assertThat(channel.feedTopicOrDefault()).isEqualTo(FeedTopic.ENTERTAINMENT);
|
||||
assertThat(channel.feedFormatOrDefault()).isEqualTo(FeedFormat.LONG_FORM);
|
||||
}
|
||||
|
||||
@Test
|
||||
void politicsSeedCanCollectRegularVideosWithoutShorts() {
|
||||
Channel channel = Channel.builder().channelId("UC-politics").title("politics").build();
|
||||
channel.configureFeed("POLITICS", "VIDEO");
|
||||
|
||||
assertThat(channel.feedTopicOrDefault()).isEqualTo(FeedTopic.POLITICS);
|
||||
assertThat(channel.feedFormatOrDefault().minDurationSec()).isEqualTo(66);
|
||||
assertThat(channel.feedFormatOrDefault().maxDurationSec()).isNull();
|
||||
}
|
||||
}
|
||||
@ -20,8 +20,8 @@ class PersonPicksTest {
|
||||
void 쇼츠는_버리고_롱폼만_남긴다() {
|
||||
List<PersonPicks.Found> found = List.of(
|
||||
f("a", 30, "C1", NOW), // 쇼츠
|
||||
f("b", 180, "C1", NOW), // 경계값 180초(유튜브 쇼츠 최대) = 쇼츠
|
||||
f("c", 181, "C1", NOW), // 롱폼
|
||||
f("b", 65, "C1", NOW), // 경계값 65초 = 쇼츠
|
||||
f("c", 66, "C1", NOW), // 롱폼
|
||||
f("d", 4300, "C1", NOW)); // 롱폼
|
||||
|
||||
List<PersonPicks.Found> kept = PersonPicks.keep(found, Set.of(), null, 0);
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ShortformCategoryTest {
|
||||
|
||||
@Test
|
||||
void legacyRegistrationDefaultsToEntertainment() {
|
||||
ShortformJob job = ShortformJob.create("https://youtu.be/abc12345678", "abc12345678");
|
||||
|
||||
assertThat(job.categoryOrDefault()).isEqualTo(ShortformCategory.ENTERTAINMENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void politicsRegistrationKeepsCategory() {
|
||||
ShortformJob job = ShortformJob.create(
|
||||
"https://youtu.be/abc12345678", "abc12345678", ShortformCategory.POLITICS);
|
||||
|
||||
assertThat(job.categoryOrDefault()).isEqualTo(ShortformCategory.POLITICS);
|
||||
}
|
||||
}
|
||||
@ -1,72 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ShortformJobResetTest {
|
||||
|
||||
private ParsedClip clip(int no, String titleMain) {
|
||||
return new ParsedClip(no, "{\"capcut\":\"json\"}", "제목 후보들", "구간표", "상단 제목", titleMain);
|
||||
}
|
||||
|
||||
private ShortformJob done() {
|
||||
ShortformJob job = ShortformJob.create("https://youtu.be/abc12345678", "abc12345678");
|
||||
job.attachStep1("스텝1 원문");
|
||||
job.applyResult("전체 Output 원문", List.of(clip(1, "첫 클립"), clip(2, "둘째 클립")));
|
||||
return job;
|
||||
}
|
||||
|
||||
@Test
|
||||
void 완료된_작업을_되돌리면_결과가_비고_대기로_간다() {
|
||||
ShortformJob job = done();
|
||||
assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.DONE);
|
||||
|
||||
job.reset();
|
||||
|
||||
assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.PENDING);
|
||||
assertThat(job.getRawOutput()).isNull();
|
||||
assertThat(job.getStep1Output()).isNull();
|
||||
assertThat(job.getTitle()).isNull();
|
||||
assertThat(job.getCompletedAt()).isNull();
|
||||
assertThat(job.getClips()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 되돌려도_등록정보는_유지된다() {
|
||||
// 삭제 후 재등록이 필요 없어야 하므로 URL·videoId 는 남아야 한다
|
||||
ShortformJob job = done();
|
||||
|
||||
job.reset();
|
||||
|
||||
assertThat(job.getYoutubeUrl()).isEqualTo("https://youtu.be/abc12345678");
|
||||
assertThat(job.getVideoId()).isEqualTo("abc12345678");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 실패한_작업도_되돌려_재시도할_수_있다() {
|
||||
ShortformJob job = ShortformJob.create("https://youtu.be/abc12345678", "abc12345678");
|
||||
job.markFailed("파싱 안 되는 원문");
|
||||
assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.FAILED);
|
||||
|
||||
job.reset();
|
||||
|
||||
assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.PENDING);
|
||||
assertThat(job.getRawOutput()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 되돌린_뒤_다시_결과를_넣을_수_있다() {
|
||||
ShortformJob job = done();
|
||||
job.reset();
|
||||
|
||||
job.applyResult("새 Output", List.of(clip(1, "새 클립")));
|
||||
|
||||
assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.DONE);
|
||||
assertThat(job.getClips()).hasSize(1);
|
||||
assertThat(job.getTitle()).isEqualTo("새 클립");
|
||||
assertThat(job.getRawOutput()).isEqualTo("새 Output");
|
||||
}
|
||||
}
|
||||
@ -52,27 +52,6 @@ class ShortformOutputParserTest {
|
||||
assertTrue(clips.get(0).durationTable().contains("총 13컷 / 총 길이: 46.5초"));
|
||||
}
|
||||
|
||||
/** "숏폼 생성기 1단계" 앱 출력: N===== 구분자 없이 candidates JSON 하나 (실전 픽스처). */
|
||||
private String step1Sample() throws Exception {
|
||||
return Files.readString(Path.of("src/test/resources/shortform/opal-step1-candidates-sample.txt"),
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidates_JSON_출력은_후보를_클립으로_파싱한다() throws Exception {
|
||||
List<ParsedClip> clips = parser.parse(step1Sample());
|
||||
|
||||
assertEquals(5, clips.size());
|
||||
ParsedClip first = clips.get(0);
|
||||
assertEquals(1, first.clipNo());
|
||||
assertEquals("냉동창고 알바생들의 현실 꼼수", first.titleTop());
|
||||
assertEquals("극한직업 뷔페 알바 첫 미션", first.titleMain());
|
||||
assertTrue(first.capcutJson().contains("\"start_time\""), "후보 원본 JSON 보존");
|
||||
assertTrue(first.durationTable().contains("02:35"));
|
||||
assertTrue(first.durationTable().contains("05:07"));
|
||||
assertEquals(5, clips.get(4).clipNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 빈_입력은_빈_리스트를_반환한다() {
|
||||
assertTrue(parser.parse(null).isEmpty());
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ShortformServiceTest {
|
||||
|
||||
@Test
|
||||
void resumesCollectingSubtitleJobsAfterRestart() {
|
||||
ShortformJobRepository repository = mock(ShortformJobRepository.class);
|
||||
ShortformSubtitleService subtitleService = mock(ShortformSubtitleService.class);
|
||||
ShortformJob job = mock(ShortformJob.class);
|
||||
when(job.getId()).thenReturn(34L);
|
||||
when(repository.findBySubtitleStatus(SubtitleStatus.COLLECTING)).thenReturn(List.of(job));
|
||||
ShortformService service = new ShortformService(
|
||||
repository, mock(ShortformOutputParser.class), subtitleService);
|
||||
|
||||
service.recoverInterruptedSubtitleCollections();
|
||||
|
||||
verify(subtitleService).collectAsync(34L);
|
||||
}
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
import com.hlab.yanalyst.global.notify.TelegramNotifier;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ShortformSubtitleServiceTest {
|
||||
|
||||
@Test
|
||||
void buildsSubtitleOnlyCommandForKoreanCaptions() {
|
||||
List<String> command = ShortformSubtitleService.buildCommand(
|
||||
"yt-dlp", "gDfGqRDubCg", Path.of("downloads/subtitles/%(id)s.%(ext)s"));
|
||||
|
||||
assertThat(command).contains("--skip-download", "--write-subs", "--write-auto-subs",
|
||||
"--sub-langs", "ko,ko-orig", "--sub-format", "vtt");
|
||||
assertThat(command).endsWith("https://www.youtube.com/watch?v=gDfGqRDubCg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidVideoIdBeforeStartingProcess() {
|
||||
assertThatThrownBy(() -> ShortformSubtitleService.buildCommand(
|
||||
"yt-dlp", "bad;id", Path.of("out")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void retriesOnceWhenFirstCollectionHasNoSubtitle() throws Exception {
|
||||
ShortformSubtitleService service = spy(new ShortformSubtitleService(
|
||||
mock(ShortformJobRepository.class), mock(ShortformWhisperService.class),
|
||||
mock(TelegramNotifier.class)));
|
||||
doReturn(null, "WEBVTT\n").when(service).collect("gDfGqRDubCg");
|
||||
|
||||
assertThat(service.collectWithRetry("gDfGqRDubCg")).isEqualTo("WEBVTT\n");
|
||||
verify(service, times(2)).collect("gDfGqRDubCg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToWhisperWhenYoutubeHasNoSubtitle() throws Exception {
|
||||
ShortformJobRepository repository = mock(ShortformJobRepository.class);
|
||||
ShortformWhisperService whisperService = mock(ShortformWhisperService.class);
|
||||
ShortformSubtitleService service = spy(new ShortformSubtitleService(
|
||||
repository, whisperService, mock(TelegramNotifier.class)));
|
||||
doReturn(null).when(service).collectWithRetry("gDfGqRDubCg");
|
||||
when(whisperService.transcribe("gDfGqRDubCg")).thenReturn("WEBVTT\n");
|
||||
|
||||
assertThat(service.collectOrTranscribe(1L, "gDfGqRDubCg")).isEqualTo("WEBVTT\n");
|
||||
verify(whisperService).transcribe("gDfGqRDubCg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectsSubtitleForEntertainmentJob() throws Exception {
|
||||
ShortformJobRepository repository = mock(ShortformJobRepository.class);
|
||||
ShortformJob job = ShortformJob.create(
|
||||
"https://www.youtube.com/watch?v=gDfGqRDubCg",
|
||||
"gDfGqRDubCg",
|
||||
ShortformCategory.ENTERTAINMENT);
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(job));
|
||||
TelegramNotifier telegramNotifier = mock(TelegramNotifier.class);
|
||||
ShortformSubtitleService service = spy(new ShortformSubtitleService(
|
||||
repository, mock(ShortformWhisperService.class), telegramNotifier));
|
||||
doReturn("WEBVTT\n").when(service).collectOrTranscribe(1L, "gDfGqRDubCg");
|
||||
|
||||
service.collectAsync(1L);
|
||||
|
||||
assertThat(job.getSubtitleStatus()).isEqualTo(SubtitleStatus.READY);
|
||||
assertThat(job.getSubtitleVtt()).isEqualTo("WEBVTT\n");
|
||||
verify(telegramNotifier).sendMessage(org.mockito.ArgumentMatchers.contains("자막 생성 완료"));
|
||||
}
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ShortformWhisperServiceTest {
|
||||
|
||||
@Test
|
||||
void buildsAudioOnlyDownloadCommand() {
|
||||
List<String> command = ShortformWhisperService.buildDownloadCommand(
|
||||
"yt-dlp", "gDfGqRDubCg", Path.of("downloads/source.%(ext)s"));
|
||||
|
||||
assertThat(command).contains("--no-playlist", "--force-overwrites", "-f", "bestaudio/best");
|
||||
assertThat(command).endsWith("https://www.youtube.com/watch?v=gDfGqRDubCg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidVideoId() {
|
||||
assertThatThrownBy(() -> ShortformWhisperService.buildDownloadCommand(
|
||||
"yt-dlp", "bad;id", Path.of("out")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertsWhisperSegmentsToVtt() {
|
||||
ScriptResponseDto.Segment first = segment(1.234, 4.567, "첫 번째 자막");
|
||||
ScriptResponseDto.Segment second = segment(65.0, 67.25, "두 번째 자막");
|
||||
|
||||
assertThat(ShortformWhisperService.toVtt(List.of(first, second)))
|
||||
.isEqualTo("WEBVTT\n\n1\n00:00:01.234 --> 00:00:04.567\n첫 번째 자막\n\n"
|
||||
+ "2\n00:01:05.000 --> 00:01:07.250\n두 번째 자막\n\n");
|
||||
}
|
||||
|
||||
private static ScriptResponseDto.Segment segment(double start, double end, String text) {
|
||||
ScriptResponseDto.Segment segment = new ScriptResponseDto.Segment();
|
||||
segment.setStart(start);
|
||||
segment.setEnd(end);
|
||||
segment.setText(text);
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"id": 1,
|
||||
"start_time": "02:35",
|
||||
"end_time": "05:07",
|
||||
"duration_sec": 152,
|
||||
"hook_point": "02:49",
|
||||
"boundary_check": "인사하며 냉동창고에 들어가는 시점에서 시작, 추위에 떨며 밖으로 도망쳐 나오는 상황 종료 시점에서 끝",
|
||||
"reason": "영케이와 츄의 티키타카가 돋보이는 구간으로, 냉동창고라는 극한의 환경 속에서 꼼수(?)를 부리는 알바생들의 현실적인 리액션과 몸개그가 큰 웃음을 유발함.",
|
||||
"title_top": "냉동창고 알바생들의 현실 꼼수",
|
||||
"title_main": "극한직업 뷔페 알바 첫 미션"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"start_time": "08:28",
|
||||
"end_time": "10:33",
|
||||
"duration_sec": 125,
|
||||
"hook_point": "08:34",
|
||||
"boundary_check": "오픈 전 인사를 연습하는 시점에서 시작, 손님 응대 후 한숨 돌리는 문장 완결 지점에서 끝",
|
||||
"reason": "오픈런 손님들을 맞이하기 위해 만반의 준비(이상한 포즈)를 했으나, 음식에 직진하는 손님들에게 철저히 투명인간 취급을 당하는 굴욕적인 상황이 완벽한 코미디를 완성함.",
|
||||
"title_top": "알바생이 투명인간이 되는 마법",
|
||||
"title_main": "오픈런 손님들의 뷔페 돌진"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"start_time": "11:42",
|
||||
"end_time": "13:14",
|
||||
"duration_sec": 92,
|
||||
"hook_point": "11:53",
|
||||
"boundary_check": "테이블 번호를 못 찾아 헤매는 시점에서 시작, 주방으로 다시 쫓겨 들어가는 혼미한 상황 종료 지점에서 끝",
|
||||
"reason": "복잡한 뷔페 홀에서 테이블 번호를 찾지 못해 넓은 홀 한가운데서 생목으로 샤우팅을 지르는 영케이의 다급함과 민망함이 시청자들에게 폭소를 안김.",
|
||||
"title_top": "뷔페 한가운데서 생목 샤우팅",
|
||||
"title_main": "길 잃은 신입 알바의 최후"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"start_time": "15:43",
|
||||
"end_time": "17:24",
|
||||
"duration_sec": 101,
|
||||
"hook_point": "15:52",
|
||||
"boundary_check": "손님의 레시피를 구경하는 시점에서 시작, 얻어먹은 멜론을 숨기려다 점장에게 걸리는 완벽한 콩트 완결 후 끝",
|
||||
"reason": "손님이 만든 기발한 디저트를 얻어먹기 위해 데이식스 영케이가 직접 라이브 라이브 헌정쇼를 펼치고, 이후 남은 멜론을 은닉하려다 걸리는 반전까지 버릴 곳 없는 알짜배기 구간.",
|
||||
"title_top": "손님 디저트 뺏어먹는 아이돌",
|
||||
"title_main": "멜론 한 조각을 위한 미니 콘서트"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"start_time": "19:48",
|
||||
"end_time": "21:22",
|
||||
"duration_sec": 94,
|
||||
"hook_point": "19:59",
|
||||
"boundary_check": "영케이가 군대 이야기를 꺼내는 시점에서 시작, JYP 연습생 시절 댄스 배틀에 대한 회상을 마무리하는 지점에서 끝",
|
||||
"reason": "설거지 지옥에 빠져 멘탈이 나간 상태에서 해병대 vs 공군 드립을 치는 웃픈 상황과 더불어, 대형 기획사(JYP) 연습생 시절 댄스 배틀을 피해 화장실에 숨었던 과거 썰이 흥미를 자극함.",
|
||||
"title_top": "JYP 전설의 연습생(?)의 비밀",
|
||||
"title_main": "설거지 지옥에서 풀린 과거 썰"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user