feat: 유튜브 예정 일정에 채널 배너 이미지 표시

- YouTube API에서 채널 정보(배너 이미지) 조회 함수 추가
- 채널 정보 Redis 캐싱 (24시간)
- 일정 상세 API에 bannerUrl 필드 추가
- 예정 일정 placeholder에 배너 이미지 배경 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-02-03 18:20:49 +09:00
parent eb7d2005b7
commit cb184e4fa5
4 changed files with 85 additions and 10 deletions

View file

@ -151,6 +151,24 @@ export default async function schedulesRoutes(fastify) {
return notFound(reply, '일정을 찾을 수 없습니다.'); return notFound(reply, '일정을 찾을 수 없습니다.');
} }
// 유튜브 카테고리인 경우 채널 배너 이미지 추가
if (result.category?.id === CATEGORY_IDS.YOUTUBE) {
const [youtubeData] = await db.query(
'SELECT channel_id FROM schedule_youtube WHERE schedule_id = ?',
[request.params.id]
);
if (youtubeData.length > 0 && youtubeData[0].channel_id) {
try {
const channelInfo = await fastify.youtubeBot.getChannelInfo(youtubeData[0].channel_id);
if (channelInfo?.bannerUrl) {
result.bannerUrl = channelInfo.bannerUrl;
}
} catch (err) {
fastify.log.warn(`채널 정보 조회 실패: ${err.message}`);
}
}
}
return result; return result;
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);

View file

@ -44,6 +44,33 @@ export async function getUploadsPlaylistId(channelId) {
return data.items[0].contentDetails.relatedPlaylists.uploads; return data.items[0].contentDetails.relatedPlaylists.uploads;
} }
/**
* 채널 정보 조회 (배너 이미지 포함)
*/
export async function getChannelInfo(channelId) {
const url = `${API_BASE}/channels?part=snippet,brandingSettings&id=${channelId}&key=${API_KEY}`;
const res = await fetch(url);
const data = await res.json();
if (data.error) {
throw new Error(data.error.message);
}
if (!data.items?.length) {
throw new Error('채널을 찾을 수 없습니다');
}
const channel = data.items[0];
const { snippet, brandingSettings } = channel;
return {
channelId,
title: snippet.title,
description: snippet.description,
thumbnailUrl: snippet.thumbnails?.high?.url || snippet.thumbnails?.default?.url,
bannerUrl: brandingSettings?.image?.bannerExternalUrl || null,
};
}
/** /**
* 영상 ID 목록으로 duration 조회 (Shorts 판별용) * 영상 ID 목록으로 duration 조회 (Shorts 판별용)
*/ */

View file

@ -1,5 +1,5 @@
import fp from 'fastify-plugin'; import fp from 'fastify-plugin';
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js'; import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId, getChannelInfo } from './api.js';
import bots from '../../config/bots.js'; import bots from '../../config/bots.js';
import { CATEGORY_IDS } from '../../config/index.js'; import { CATEGORY_IDS } from '../../config/index.js';
import { withTransaction } from '../../utils/transaction.js'; import { withTransaction } from '../../utils/transaction.js';
@ -7,6 +7,7 @@ import { syncScheduleById, deleteSchedule } from '../meilisearch/index.js';
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE; const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:'; const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
const CHANNEL_INFO_PREFIX = 'yt_channel:';
async function youtubeBotPlugin(fastify, opts) { async function youtubeBotPlugin(fastify, opts) {
/** /**
@ -28,6 +29,25 @@ async function youtubeBotPlugin(fastify, opts) {
return playlistId; return playlistId;
} }
/**
* 채널 정보 조회 (Redis 캐싱, 24시간)
*/
async function getCachedChannelInfo(channelId) {
const cacheKey = `${CHANNEL_INFO_PREFIX}${channelId}`;
// Redis 캐시 확인
const cached = await fastify.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// API 호출 후 캐싱 (24시간)
const channelInfo = await getChannelInfo(channelId);
await fastify.redis.set(cacheKey, JSON.stringify(channelInfo), 'EX', 86400);
return channelInfo;
}
/** /**
* 다음 특정 요일 날짜 계산 (KST 기준) * 다음 특정 요일 날짜 계산 (KST 기준)
* @param {number} targetDay - 목표 요일 (0=, 4=) * @param {number} targetDay - 목표 요일 (0=, 4=)
@ -395,6 +415,7 @@ async function youtubeBotPlugin(fastify, opts) {
syncNewVideos, syncNewVideos,
syncAllVideos, syncAllVideos,
getManagedChannelIds, getManagedChannelIds,
getChannelInfo: getCachedChannelInfo,
}); });
} }

View file

@ -83,19 +83,28 @@ function VideoInfo({ schedule, isShorts, isScheduled = false }) {
/** /**
* 예정 일정 Placeholder 컴포넌트 * 예정 일정 Placeholder 컴포넌트
*/ */
function ScheduledPlaceholder() { function ScheduledPlaceholder({ bannerUrl }) {
return ( return (
<div className="relative aspect-video bg-gradient-to-br from-gray-800 to-gray-900 rounded-2xl overflow-hidden shadow-lg shadow-black/10 flex flex-col items-center justify-center"> <div className="relative aspect-video bg-gradient-to-br from-gray-800 to-gray-900 rounded-2xl overflow-hidden shadow-lg shadow-black/10 flex flex-col items-center justify-center">
{/* 배경 패턴 */} {/* 배경: 배너 이미지 또는 패턴 */}
<div className="absolute inset-0 opacity-5"> {bannerUrl ? (
<div className="absolute inset-0" style={{ <div
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`, className="absolute inset-0 bg-cover bg-center"
}} /> style={{ backgroundImage: `url(${bannerUrl})` }}
</div> >
<div className="absolute inset-0 bg-black/50" />
</div>
) : (
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
)}
{/* 유튜브 아이콘 */} {/* 유튜브 아이콘 */}
<div className="relative mb-4"> <div className="relative mb-4">
<div className="w-20 h-20 bg-red-500/20 rounded-full flex items-center justify-center"> <div className="w-20 h-20 bg-red-500/20 backdrop-blur-sm rounded-full flex items-center justify-center">
<svg className="w-10 h-10 text-red-500" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-10 h-10 text-red-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" /> <path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg> </svg>
@ -132,7 +141,7 @@ function YoutubeSection({ schedule }) {
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
className="w-full" className="w-full"
> >
<ScheduledPlaceholder /> <ScheduledPlaceholder bannerUrl={schedule.bannerUrl} />
</motion.div> </motion.div>
{/* 영상 정보 카드 */} {/* 영상 정보 카드 */}