발굴 대상이 SHORTS 하드코딩이었으나, 롱폼 콘텐츠 대응을 위해 포맷을 선택 가능하게 함.
- ChannelDiscoveryService: runDiscovery(String format) 오버로드. format(SHORTS|LONG_FORM),
null/미인식이면 설정 기본값(hlab.discovery.format, 기본 LONG_FORM) 사용. 검색 조건의
하드코딩 SHORTS를 fmt로 교체(키워드 없는 검색·키워드 검색 양쪽).
- application.yml: hlab.discovery.format 추가(기본 LONG_FORM, DISCOVERY_FORMAT env로 오버라이드).
- RecommendedChannelController /run: format 쿼리파라미터 수용.
- recommend.html: '지금 발굴' 옆 롱폼/쇼츠 선택 드롭다운(롱폼 기본), 소제목 KR·롱폼 반영.
동일 파일에 있던 진행 중 기능(추천채널 대표영상 '영상 재가공' 버튼 + /{id}/rework 엔드포인트)도 함께 포함.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
예능 전환 2단계 — 소재 발굴을 예능 쪽으로 강화하고, 매일 아침 추천을 자동으로 받는다.
발굴 예능 특화
- ChannelDiscoveryService: 지역 인기 Shorts 발굴에 더해, 설정된 예능 키워드
(hlab.discovery.keywords, 기본 "예능,방송,하이라이트")로 지역×키워드 Shorts를
조회수순 검색해 예능 클립 소재 채널을 우선 발굴.
- 매 검색마다 쿼터 가드, 소진 시 전체 발굴 중단(라벨 break). 키워드 비면 기존 동작 유지.
텔레그램 아침 추천
- TelegramNotifier 신규(global/notify): bot-token/chat-id 미설정 시 no-op,
발송 예외는 삼켜 호출측을 깨지 않음. @Qualifier("restTemplate") 주입.
- 매일 04:30 발굴 직후 NEW 추천채널 상위 N개를 한국어 HTML 다이제스트로 발송.
- 활성화: env TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID 설정.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
느려짐의 유력 원인 두 가지를 정리한다.
1) p6spy가 모든 SQL을 파라미터값 포함해 매번 로깅하던 것을 기본 OFF로.
디버깅 시 P6SPY_LOGGING=true 로 켠다.
2) channel_videos에 인덱스가 하나도 없어 데이터가 늘면 풀스캔이었다.
리포지토리의 실제 필터/정렬 컬럼에 인덱스 추가(ddl-auto가 자동 생성):
video_id, channel_id, interest_status, source, category_id,
published_at, views_per_sub_ratio
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
유통(내가 올려서 발행)에서 예능 소재 파이프라인으로 방향을 틀면서
발행 큐 기능을 제거한다. domain/publish 전체와 발행 준비 UI(재가공 화면),
사이드바 발행 큐 링크, 대시보드 발행 현황 위젯을 걷어냈다.
- domain/publish/* (Controller/Service/Repository/Entity) 삭제
- publish.html · PublishServiceTest 삭제
- rework.html 발행 준비 섹션·JS 제거
- dashboard.html · DashboardService 발행 집계 제거
- WebController /publish 라우트, sidebar 링크 제거
- ChannelVideo.publishedAt(원본 게시일)은 발행 기능과 무관하여 그대로 유지
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Channel 에 country 추가(YouTube snippet.country 저장), 추천 채널 region 으로 기존 채널
백필(POST /api/channels/backfill-country), /channels 에 국가 탭+카운트 필터.
선택 영상 보기 버튼 한글화.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 목록 API status 파라미터(NEW|EXCLUDED|REGISTERED), POST /{id}/restore(→NEW)
- recommend.html: 헤더 '제외 목록' 토글로 EXCLUDED 채널 보기, 카드에 '복원' 버튼
(제외 보기에선 등록/제외 대신 복원+채널이동만), 지역 탭은 그대로 적용
검증: 제외→EXCLUDED 목록 노출→복원→NEW 복귀 end-to-end 확인.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- region 을 채널 국가가 아닌 '검색 발견 지역(regionCode)'으로 태깅(UNKNOWN 해소)
- upsert: 같은 배율이면 갱신(지역 등 메타 최신화)
- recommend.html: 전체/KR/JP/US 탭으로 필터, 등록/제외 시 목록 갱신
검증: 재발굴 후 KR14·JP7·US1 로 분포, 탭 필터 동작.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
화면(Gemini)·음성 패널에서 원문과 한국어 번역을 함께 보여줘 비교 가능하게 함.
- ScriptResponseDto.Segment: original 필드 추가(NON_NULL)
- Gemini 프롬프트/스키마: original(화면 원문) + text(한국어 번역) 둘 다 요청,
text 는 반드시 한국어(영어 방지) 명시
- rework.html: 각 줄에 한국어(위)+원문(아래 회색) 표시, 음성 패널은 Whisper 원문 사용
검증: 470 화면자막 → 원문 일본어 + 한국어 번역 + 진행형 타임스탬프 정상.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
재가공 화면에 'AI 자막(음성/화면)' 카드 추가. '생성' 한 번에:
- 왼쪽(음성): 저장된 Whisper 세그먼트(정밀 타임스탬프) → LibreTranslate 한국어
- 오른쪽(화면): 유튜브 URL → Gemini가 화면 박힌 자막 추출+한국어 번역
각 패널을 [00:00] 한국어 리스트로 표시, 각각 SRT 다운로드(audio_ko/screen_ko).
- GeminiSubtitleService 신규: youtube URL을 generativelanguage API에 전송,
responseMimeType=json 구조화 응답 파싱. buildRequest/extractSegments/sanitizeApiKey 순수+단위테스트
- sanitizeApiKey: 잘못 붙은 선행 '='·공백 정리(env var 오타 방어)
- geminiRestTemplate 빈(5분), POST /{id}/gemini-subtitles, CurationService.geminiScreenSubtitles
- 설정 gemini.api-key/model(gemini-2.5-flash), rework.html 분할 카드+JS
- 음성 한국어는 기존 translate(format=segments) 재사용
검증: 470 화면자막 → 한국어 27세그먼트 30초 추출(스타일 일본어 자막도 정확).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tesseract 기반 OCR이 실제 쇼츠의 스타일 자막을 거의 못 읽어 실효성이 없고,
URL→Gemini 방향으로 전환하기로 해 h-lab의 OCR 연동을 제거한다.
- 컨트롤러 POST /{id}/ocr 제거
- CurationService.ocrScreenSubtitles 제거
- ChannelService.ocrFromCached/joinSegmentText 제거(persistScript는 전사가 계속 사용)
- rework.html '화면 자막 OCR' 버튼·crop/fps 입력·ocrScreen() 제거
- docs/python-service/ocr_video_endpoint.py(videocr 예시) 삭제
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
기존엔 '원본 다운로드'가 다운로드+Whisper전사를 한 동기호출로 처리해, 전사 서버
(h-python)가 느리거나 타임아웃나면 화면이 '다운로드 중'에서 안 풀렸다.
다운로드(빠름)와 전사(분리)를 나눠, 다운로드는 즉시 끝나고 영상이 바로 뜨며
전사가 실패해도 다운로드 결과는 유지(화면자막 OCR/재시도 가능).
- POST /{id}/download: 다운로드만(캐시), 응답 {downloaded, sizeBytes}
- POST /{id}/transcribe-cached: 받은 원본 전사(분리)
- downloadOriginal(): 1)다운로드 즉시완료·영상표시 2)전사 별도(실패해도 다운로드 유지)
검증: /download 2.7초 완료. 전사는 분리 단계로 진행/실패 격리.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OCR 정확도·속도 향상을 위해 '자막영역 하단 N%' 입력을 추가, 플레이어 영상의 실제
해상도로 crop 픽셀(crop_x/y/width/height)을 환산해 /ocr_video 로 전달한다.
100%면 use_fullframe=true, 해상도 못 읽으면 생략(서버 기본 하단30%).
- ocrFromCached(file, formParams Map)로 일반화 — 서버 필드명 그대로 전달
- CurationService/Controller에 useFullframe·cropX/Y/Width/Height 파라미터 추가
- rework.html: 자막영역 % 입력 + ocrScreen()이 영상 해상도로 crop 계산
검증: sample_fps=3 + 하단30% crop 으로 2m28s 완료(프록시 600s 적용 후 504 해소),
crop 전달 정상 동작 확인.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
받은 원본 영상을 Python /ocr_video(ffmpeg+Tesseract)로 보내 화면에 박힌 자막을
시간 싱크 세그먼트로 추출·저장한다. 음성 전사와 같은 자리(ChannelVideoScript)에
저장되어 스크립트 리스트·SRT·번역·한국어 SRT에 그대로 흐른다.
- ChannelService.ocrFromCached + persistScript 공통 추출(전사/OCR 공유), joinSegmentText
- CurationService.ocrScreenSubtitles(받은 원본 캐시 사용), POST /{id}/ocr
- rework.html '화면 자막 OCR' 버튼 + ocrScreen()
- sampleFps/confThreshold null이면 미전송 → 서버 기본값 사용
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
자가호스팅 LibreTranslate(h-etc2.tolag.shop) 연동. 재가공 화면 '번역→재작성'
버튼으로 원본 전사 스크립트를 한국어로 번역해 재작성 칸 초안으로 채운다.
소스 언어는 전사 언어로 자동(없으면 auto), 타깃 기본 ko.
- TranslateService 신규: POST /translate {q,source,target} → translatedText
(요청 조립/응답 파싱 순수 메서드 + 단위테스트)
- POST /{id}/translate 엔드포인트, CurationService.translateScript
- rework.html: '번역→재작성' 버튼 + translateToEditor()
- 설정 translate.base-url(기본값 有)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
원본 다운로드/캐시 보유 시 왼쪽 플레이어를 YouTube 임베드 대신 받은 원본 mp4로
전환한다. 세그먼트(타임라인) 클릭 시 기존 seekTo/하이라이트가 그대로 동작해
해당 지점으로 이동·재생된다. (업로드본이 있으면 업로드본 우선)
- GET /{id}/download/file: 받은 원본 스트리밍(Resource 반환 → HTTP Range 206 자동 지원)
- CurationService.cachedDownloadFile(id)
- rework.html: showServerVideoInPlayer/showYoutubeInPlayer, updateCacheUI가 플레이어 전환,
다운로드 직후/진입 시 캐시 있으면 자동 적용, 삭제 시 YouTube로 복귀
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1) 재가공 화면에 '받은 원본 삭제' 버튼 추가(캐시 보유 시에만 노출).
GET/DELETE /{id}/download 로 캐시 상태 조회·삭제(자막/세그먼트는 유지).
진입 시 캐시 상태를 조회해 삭제버튼 노출 + 렌더 캐시 사용 여부를 갱신.
2) 수집함 영상 삭제 시 downloads/{id}.mp4 도 함께 삭제(고아 파일 방지).
- VideoDownloadService.deleteCache(id) + 단위테스트(@TempDir)
- CurationService: downloadStatus/deleteDownloadCache, delete() 캐시 정리
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
재가공 화면 '원본 다운로드' 버튼으로 저장된 videoId를 yt-dlp(로컬 ProcessBuilder)로
받아 서버에 캐시하고, 그 파일을 기존 Python /transcribe·/render 에 그대로 투입한다.
수동 다운로드+업로드 단계를 전사·렌더 양쪽에서 제거.
- VideoDownloadService 신규: 인자 리스트 ProcessBuilder(셸 미사용), videoId 검증, 캐시 조회
- ChannelService: 전사·렌더를 Resource 기반 공통 메서드로 추출(업로드/캐시 공유)
- 컨트롤러: POST /{id}/download(다운로드+전사), /render 의 file 을 선택값으로(없으면 캐시)
- rework.html: '원본 다운로드' 버튼 + downloadOriginal(), 렌더가 서버 캐시 재사용
- 설정 ytdlp.*/download.dir(기본값 有), downloads/ gitignore
- buildCommand/videoId 검증 단위테스트(src/test 신규)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups (safe fixes):
- pythonRestTemplate bean (10min read timeout) for /transcribe and /render so
ffmpeg encoding / Whisper don't hit the shared 120s timeout; resolved by bean
name so existing restTemplate injections are unaffected
- getScriptData: fetch the script row once instead of 3 separate queries
- renderTrimmed "no segments" now IllegalArgumentException (400 + visible message)
instead of IllegalStateException swallowed as generic 500
- ProductionService: System.out.println -> log.info (3x)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3: remove "no-talk" gaps (Whisper-segment based, not audio silencedetect
which finds nothing under background music) and render a trimmed (+speed) video
via ffmpeg, with subtitles remapped to match.
- KeepIntervalPlanner + TimelineRemapper (pure, unit-tested): keep/remove plan
from segments (pad/minGap) and timestamp remap f(t)=t-removedBefore(t)
- GET /{id}/trim-plan (preview: keep/remove/remapped segments/kept duration)
- POST /{id}/render (multipart: file,pad,minGap,speed) -> proxy Python /render
(ffmpeg trim/atrim+concat+atempo) -> mp4 download; ffmpeg graph validated locally
- rework.html: export panel (speed + speech-gap trim preview + SRT/video export),
client-side SRT from working segments, language selector (auto/ko/en/zh/ja)
- transcribeFromFile forwards optional language (Whisper auto-detect misfired -> zh)
Spec updated with the audio-silence -> speech-gap design correction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dashboardSummary surfaced the earliest-scheduled packages as "recent"
(it reused the queue's scheduledAt-asc sort); query updatedAt-desc instead.
Build byStatus from an explicit ordered list since Set.of iteration order
is undefined, so the dashboard renders DRAFT/READY/PUBLISHED consistently.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The discover @Query used the `(:param is null or col >= :param)` idiom.
For the timestamp (publishedAfter) and numeric (minRatio) params, Postgres
threw "could not determine data type of parameter $1" because the bare
`$1 is null` placeholder is untyped — returning HTTP 500 and breaking the
whole /discover page. (The search() query survives because its nullable
params are only bigint/varchar, which Postgres can null-type.) Wrap the
two problematic params in cast(... as timestamp/big_decimal) in the
is-null check so the type is explicit. ORDER BY ... nulls last / fetch
first were never the problem.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add GET /api/dashboard/summary aggregating pipeline status, category
distribution, publish summary, and outperformers in one call. Rewrite
dashboard.html with 5 KPI cards, pipeline funnel, publish status, and
category/source-format breakdowns (CSS bars, no chart lib).
Backend: ChannelVideoRepository counts (shorts/uncategorized),
PublishPackageRepository.countByStatus, pipelineStats shorts/longForm,
CategoryService.distribution, PublishService.dashboardSummary, new
DashboardService + DashboardApiController.
Fix: PublishService.list(null) hit UnsupportedOperationException because
findAll(Sort) uses Criteria, which rejects nullsLast precedence. Route the
no-status path through a @Query method so Sort is appended as HQL ORDER BY
(supports NULLS LAST). Also fixes the latent bug in /api/v1/publish all-list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The legacy Opal content pipeline (YtVideo + ScriptGen + OpalDraft/Final/
FinalAsset, driven by AnalysisWorkflowService via hardcoded Google Docs)
is no longer used. The active flow is ChannelVideo: collect -> curate
(board) -> rework -> publish.
Removed:
- service: AnalysisWorkflowService, YtVideoService, external/ExternalApiService(+Impl/Stub)
- web: YtVideoController, VideoActionController (/api/videos), video_detail.html
- web/dto: Video{Response,SearchCondition,AddRequest,DetailResponse},
FinalAssetResponse, OpalDraftResponse, DraftGenerateRequest
- domain/video: YtVideo, YtVideoRepository, dto/Video{List,Detail}Response
- domain/script: ScriptGen(+Repository)
- domain/opal: OpalDraft/OpalFinal/OpalFinalAsset(+Repositories, dto)
Preserved the active YouTube search by extracting searchYoutubeVideos()
into a new dedicated YoutubeSearchService (no Opal deps); rewired
YoutubeSearchApiController. WebController drops the /videos/{id} Opal
detail route + YtVideoService dependency.
DB note: ddl-auto=update never drops tables, so yt_video / scriptgen /
opal_* remain as orphaned tables (harmless, no data loss). Verified by
clean compileJava + reference sweep across java/html/yml.
Video/VideoRepository/VideoService/VideoController (table 'videos',
/api/v1/videos) were a legacy read-model with zero cross-package
references. The active flows use ChannelVideo (collection→rework→publish)
and YtVideo (Opal pipeline). Verified by clean compileJava.