diff --git a/backend/sql/youtube_bots.sql b/backend/sql/youtube_bots.sql new file mode 100644 index 0000000..49f23ab --- /dev/null +++ b/backend/sql/youtube_bots.sql @@ -0,0 +1,25 @@ +-- YouTube 봇 테이블 +CREATE TABLE IF NOT EXISTS youtube_bots ( + id INT AUTO_INCREMENT PRIMARY KEY, + channel_id VARCHAR(30) NOT NULL, + channel_handle VARCHAR(50), + channel_name VARCHAR(100) NOT NULL, + banner_url VARCHAR(500), + cron_interval INT DEFAULT 2, + enabled TINYINT(1) DEFAULT 1, + + -- 제목 필터 (선택, JSON 배열) + title_filters JSON, + + -- 멤버 설정 (선택) + default_member_ids JSON, + extract_members_from_desc TINYINT(1) DEFAULT 0, + + -- 다음 주 예정 일정 설정 (JSON) + auto_schedule_config JSON, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY uk_channel_id (channel_id) +); diff --git a/backend/sql/youtube_bots_seed.sql b/backend/sql/youtube_bots_seed.sql new file mode 100644 index 0000000..6ed42d4 --- /dev/null +++ b/backend/sql/youtube_bots_seed.sql @@ -0,0 +1,20 @@ +-- YouTube 봇 시드 데이터 +-- channel_handle은 봇 추가 시 YouTube API로 조회하여 저장 + +INSERT INTO youtube_bots (channel_id, channel_name, cron_interval, enabled) VALUES + ('UCXbRURMKT3H_w8dT-DWLIxA', 'fromis_9', 2, 1), + ('UCtfyAiqf095_0_ux8ruwGfA', 'MUSINSA TV', 2, 1), + ('UCeUJ8B3krxw8zuDi19AlhaA', '스프 : 스튜디오 프로미스나인', 2, 1) +ON DUPLICATE KEY UPDATE channel_name = VALUES(channel_name); + +-- 스프 : 스튜디오 프로미스나인 - 예정 일정 설정 +UPDATE youtube_bots +SET auto_schedule_config = '{"dayOfWeek":4,"time":"18:00:00","titleTemplate":"{channelName} {episode}화","deadlineDayOfWeek":5,"excludeShorts":true}' +WHERE channel_id = 'UCeUJ8B3krxw8zuDi19AlhaA'; + +-- MUSINSA TV - 필터/멤버 설정 +UPDATE youtube_bots +SET title_filters = '["성수기"]', + default_member_ids = '[7]', + extract_members_from_desc = 1 +WHERE channel_id = 'UCtfyAiqf095_0_ux8ruwGfA'; diff --git a/backend/src/config/bots.js b/backend/src/config/bots.js index 789052d..0167e69 100644 --- a/backend/src/config/bots.js +++ b/backend/src/config/bots.js @@ -1,3 +1,4 @@ +// 정적 봇 설정 (YouTube 봇은 DB에서 관리) export default [ { id: 'meilisearch-sync', @@ -6,41 +7,6 @@ export default [ cron: '0 0 * * *', // 매일 00시 전체 동기화 enabled: true, }, - { - id: 'youtube-fromis9', - type: 'youtube', - channelId: 'UCXbRURMKT3H_w8dT-DWLIxA', - channelName: 'fromis_9', - cron: '*/2 * * * *', - enabled: true, - }, - { - id: 'youtube-studio', - type: 'youtube', - channelId: 'UCeUJ8B3krxw8zuDi19AlhaA', - channelName: '스프 : 스튜디오 프로미스나인', - cron: '*/2 * * * *', - enabled: true, - // 다음 주 예정 일정 자동 생성 - autoScheduleNext: { - dayOfWeek: 4, // 목요일 (0=일요일) - time: '18:00:00', - titleTemplate: '{channelName} {episode}화', // {episode}는 자동 계산 - deadlineDayOfWeek: 5, // 금요일 00시까지 영상 없으면 삭제 - excludeShorts: true, // 쇼츠는 제외 - }, - }, - { - id: 'youtube-musinsa', - type: 'youtube', - channelId: 'UCtfyAiqf095_0_ux8ruwGfA', - channelName: 'MUSINSA TV', - cron: '*/2 * * * *', - enabled: true, - titleFilter: '성수기', - defaultMemberId: 7, - extractMembersFromDesc: true, - }, { id: 'x-fromis9', type: 'x', diff --git a/backend/src/plugins/scheduler.js b/backend/src/plugins/scheduler.js index 0de1257..8945b22 100644 --- a/backend/src/plugins/scheduler.js +++ b/backend/src/plugins/scheduler.js @@ -1,6 +1,6 @@ import fp from 'fastify-plugin'; import cron from 'node-cron'; -import bots from '../config/bots.js'; +import staticBots from '../config/bots.js'; import { syncAllSchedules } from '../services/meilisearch/index.js'; import { nowKST } from '../utils/date.js'; @@ -9,6 +9,63 @@ const TIMEZONE = 'Asia/Seoul'; async function schedulerPlugin(fastify, opts) { const tasks = new Map(); + let cachedBots = null; + + /** + * DB에서 YouTube 봇 목록 조회 + */ + async function getYouTubeBotsFromDB() { + const [rows] = await fastify.db.query( + 'SELECT * FROM youtube_bots WHERE enabled = 1' + ); + return rows.map(row => ({ + id: `youtube-${row.id}`, // DB ID를 문자열 형식으로 변환 + dbId: row.id, + type: 'youtube', + channelId: row.channel_id, + channelHandle: row.channel_handle, + channelName: row.channel_name, + bannerUrl: row.banner_url, + cron: `*/${row.cron_interval} * * * *`, + enabled: row.enabled === 1, + titleFilters: row.title_filters + ? (typeof row.title_filters === 'string' + ? JSON.parse(row.title_filters) + : row.title_filters) + : [], + defaultMemberIds: row.default_member_ids + ? (typeof row.default_member_ids === 'string' + ? JSON.parse(row.default_member_ids) + : row.default_member_ids) + : [], + extractMembersFromDesc: row.extract_members_from_desc === 1, + autoScheduleNext: row.auto_schedule_config + ? (typeof row.auto_schedule_config === 'string' + ? JSON.parse(row.auto_schedule_config) + : row.auto_schedule_config) + : null, + })); + } + + /** + * 모든 봇 목록 가져오기 (정적 + DB) + */ + async function getAllBots(forceRefresh = false) { + if (cachedBots && !forceRefresh) { + return cachedBots; + } + const youtubeBots = await getYouTubeBotsFromDB(); + cachedBots = [...staticBots, ...youtubeBots]; + return cachedBots; + } + + /** + * 봇 ID로 봇 찾기 + */ + async function findBot(botId) { + const allBots = await getAllBots(); + return allBots.find(b => b.id === botId); + } /** * 봇 상태 Redis에 저장 @@ -56,10 +113,10 @@ async function schedulerPlugin(fastify, opts) { } /** - * 동기화 결과 처리 (중복 코드 제거) + * 동기화 결과 처리 */ async function handleSyncResult(botId, result, options = {}) { - const { setRunningStatus = false, setErrorOnFail = false } = options; + const { setRunningStatus = false } = options; const status = await getStatus(botId); const updateData = { lastCheckAt: nowKST(), @@ -80,7 +137,7 @@ async function schedulerPlugin(fastify, opts) { * 봇 시작 */ async function startBot(botId) { - const bot = bots.find(b => b.id === botId); + const bot = await findBot(botId); if (!bot) { throw new Error(`봇을 찾을 수 없습니다: ${botId}`); } @@ -145,7 +202,8 @@ async function schedulerPlugin(fastify, opts) { * 모든 활성 봇 시작 */ async function startAll() { - for (const bot of bots) { + const allBots = await getAllBots(true); // DB에서 새로 로드 + for (const bot of allBots) { if (bot.enabled) { try { await startBot(bot.id); @@ -167,6 +225,13 @@ async function schedulerPlugin(fastify, opts) { tasks.clear(); } + /** + * 봇 캐시 갱신 (봇 추가/수정/삭제 시 호출) + */ + function invalidateCache() { + cachedBots = null; + } + // 데코레이터 등록 fastify.decorate('scheduler', { startBot, @@ -174,7 +239,8 @@ async function schedulerPlugin(fastify, opts) { startAll, stopAll, getStatus, - getBots: () => bots, + getBots: () => getAllBots(), + invalidateCache, }); // 앱 종료 시 모든 봇 정지 diff --git a/backend/src/routes/admin/bots.js b/backend/src/routes/admin/bots.js index e75980d..6202f2a 100644 --- a/backend/src/routes/admin/bots.js +++ b/backend/src/routes/admin/bots.js @@ -1,4 +1,3 @@ -import bots from '../../config/bots.js'; import { errorResponse } from '../../schemas/index.js'; import { syncAllSchedules } from '../../services/meilisearch/index.js'; import { badRequest, notFound, serverError } from '../../utils/error.js'; @@ -57,9 +56,10 @@ export default async function botsRoutes(fastify) { }, preHandler: [fastify.authenticate], }, async (request, reply) => { + const allBots = await scheduler.getBots(); const result = []; - for (const bot of bots) { + for (const bot of allBots) { const status = await scheduler.getStatus(bot.id); // cron 표현식에서 간격 추출 (분 단위, 일일 스케줄은 1440분) @@ -187,7 +187,8 @@ export default async function botsRoutes(fastify) { }, async (request, reply) => { const { id } = request.params; - const bot = bots.find(b => b.id === id); + const allBots = await scheduler.getBots(); + const bot = allBots.find(b => b.id === id); if (!bot) { return notFound(reply, '봇을 찾을 수 없습니다.'); } diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index 47237e0..dedf49a 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -154,18 +154,14 @@ export default async function schedulesRoutes(fastify) { // 유튜브 카테고리인 경우 채널 배너 이미지 추가 if (result.category?.id === CATEGORY_IDS.YOUTUBE) { const [youtubeData] = await db.query( - 'SELECT channel_id FROM schedule_youtube WHERE schedule_id = ?', + `SELECT sy.channel_id, yb.banner_url + FROM schedule_youtube sy + LEFT JOIN youtube_bots yb ON sy.channel_id = yb.channel_id + WHERE sy.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}`); - } + if (youtubeData.length > 0 && youtubeData[0].banner_url) { + result.bannerUrl = youtubeData[0].banner_url; } } diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js index a4a4dce..3eaab15 100644 --- a/backend/src/services/x/index.js +++ b/backend/src/services/x/index.js @@ -2,7 +2,6 @@ import fp from 'fastify-plugin'; import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js'; import { fetchVideoInfo } from '../youtube/api.js'; import { formatDate, formatTime, nowKST } from '../../utils/date.js'; -import bots from '../../config/bots.js'; import { withTransaction } from '../../utils/transaction.js'; import { syncScheduleById } from '../meilisearch/index.js'; @@ -13,12 +12,13 @@ const PROFILE_TTL = 604800; // 7일 async function xBotPlugin(fastify, opts) { /** - * 관리 중인 YouTube 채널 ID 목록 + * 관리 중인 YouTube 채널 ID 목록 (DB에서 조회) */ - function getManagedChannelIds() { - return bots - .filter(b => b.type === 'youtube') - .map(b => b.channelId); + async function getManagedChannelIds() { + const [rows] = await fastify.db.query( + 'SELECT channel_id FROM youtube_bots WHERE enabled = 1' + ); + return rows.map(r => r.channel_id); } /** @@ -131,7 +131,7 @@ async function xBotPlugin(fastify, opts) { const videoIds = extractYoutubeVideoIds(tweet.text); if (videoIds.length === 0) return 0; - const managedChannels = getManagedChannelIds(); + const managedChannels = await getManagedChannelIds(); let addedCount = 0; for (const videoId of videoIds) { diff --git a/backend/src/services/youtube/api.js b/backend/src/services/youtube/api.js index 90d6455..779f594 100644 --- a/backend/src/services/youtube/api.js +++ b/backend/src/services/youtube/api.js @@ -97,9 +97,8 @@ async function getVideoDurations(videoIds) { * 최근 N개 영상 조회 (Activities API 사용 - playlistItems보다 빠른 반영) * @param {string} channelId - 채널 ID * @param {number} maxResults - 최대 결과 수 - * @param {string} uploadsPlaylistId - 미사용 (하위 호환성 유지) */ -export async function fetchRecentVideos(channelId, maxResults = 10, uploadsPlaylistId = null) { +export async function fetchRecentVideos(channelId, maxResults = 10) { // Activities API에 type=upload 지정 (다른 활동이 섞일 수 있어 2배 조회) const fetchCount = Math.min(maxResults * 2, 50); const url = `${API_BASE}/activities?part=snippet,contentDetails&channelId=${channelId}&type=upload&maxResults=${fetchCount}&key=${API_KEY}`; diff --git a/backend/src/services/youtube/index.js b/backend/src/services/youtube/index.js index 37a41a5..5e9c0a7 100644 --- a/backend/src/services/youtube/index.js +++ b/backend/src/services/youtube/index.js @@ -1,53 +1,12 @@ import fp from 'fastify-plugin'; -import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId, getChannelInfo } from './api.js'; -import bots from '../../config/bots.js'; +import { fetchRecentVideos, fetchAllVideos } from './api.js'; import { CATEGORY_IDS } from '../../config/index.js'; import { withTransaction } from '../../utils/transaction.js'; import { syncScheduleById, deleteSchedule } from '../meilisearch/index.js'; const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE; -const PLAYLIST_CACHE_PREFIX = 'yt_uploads:'; -const CHANNEL_INFO_PREFIX = 'yt_channel:'; - -async function youtubeBotPlugin(fastify, opts) { - /** - * uploads playlist ID 조회 (Redis 캐싱) - */ - async function getCachedUploadsPlaylistId(channelId) { - const cacheKey = `${PLAYLIST_CACHE_PREFIX}${channelId}`; - - // Redis 캐시 확인 - const cached = await fastify.redis.get(cacheKey); - if (cached) { - return cached; - } - - // API 호출 후 캐싱 (영구 저장 - 값이 변하지 않음) - const playlistId = await getUploadsPlaylistId(channelId); - await fastify.redis.set(cacheKey, 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; - } +async function youtubeBotPlugin(fastify) { /** * 다음 특정 요일 날짜 계산 (KST 기준) * @param {number} targetDay - 목표 요일 (0=일, 4=목) @@ -285,8 +244,12 @@ async function youtubeBotPlugin(fastify, opts) { } // 커스텀 설정 적용 - if (bot.titleFilter && !video.title.includes(bot.titleFilter)) { - return null; + // 제목 필터: 하나라도 포함되어야 통과 + if (bot.titleFilters && bot.titleFilters.length > 0) { + const matchesFilter = bot.titleFilters.some((filter) => video.title.includes(filter)); + if (!matchesFilter) { + return null; + } } const { autoScheduleNext } = bot; @@ -328,10 +291,11 @@ async function youtubeBotPlugin(fastify, opts) { ); // 멤버 연결 (커스텀 설정) - if (bot.defaultMemberId || bot.extractMembersFromDesc) { + const hasDefaultMembers = bot.defaultMemberIds && bot.defaultMemberIds.length > 0; + if (hasDefaultMembers || bot.extractMembersFromDesc) { const memberIds = []; - if (bot.defaultMemberId) { - memberIds.push(bot.defaultMemberId); + if (hasDefaultMembers) { + memberIds.push(...bot.defaultMemberIds); } if (nameMap) { memberIds.push(...extractMemberIds(video.description, nameMap)); @@ -366,8 +330,7 @@ async function youtubeBotPlugin(fastify, opts) { await checkScheduledDeadline(bot); } - const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId); - const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId); + const videos = await fetchRecentVideos(bot.channelId, 10); let addedCount = 0; for (const video of videos) { @@ -386,8 +349,7 @@ async function youtubeBotPlugin(fastify, opts) { * 전체 영상 동기화 (초기화) */ async function syncAllVideos(bot) { - const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId); - const videos = await fetchAllVideos(bot.channelId, uploadsPlaylistId); + const videos = await fetchAllVideos(bot.channelId); let addedCount = 0; for (const video of videos) { @@ -403,23 +365,23 @@ async function youtubeBotPlugin(fastify, opts) { } /** - * 관리 중인 채널 ID 목록 + * 관리 중인 채널 ID 목록 (DB에서 조회) */ - function getManagedChannelIds() { - return bots - .filter(b => b.type === 'youtube') - .map(b => b.channelId); + async function getManagedChannelIds() { + const [rows] = await fastify.db.query( + 'SELECT channel_id FROM youtube_bots WHERE enabled = 1' + ); + return rows.map(r => r.channel_id); } fastify.decorate('youtubeBot', { syncNewVideos, syncAllVideos, getManagedChannelIds, - getChannelInfo: getCachedChannelInfo, }); } export default fp(youtubeBotPlugin, { name: 'youtubeBot', - dependencies: ['db', 'redis'], + dependencies: ['db'], }); diff --git a/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx b/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx index 07acf22..400b430 100644 --- a/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx +++ b/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx @@ -4,7 +4,8 @@ import { useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { motion, AnimatePresence } from 'framer-motion'; -import { Youtube, Search, X, ChevronDown, ChevronUp, Clock } from 'lucide-react'; +import { Youtube, Search, X, ChevronDown, ChevronUp } from 'lucide-react'; +import { getMembers } from '@/api/public/members'; // 동기화 간격 옵션 const INTERVAL_OPTIONS = [ @@ -34,15 +35,22 @@ const TIME_OPTIONS = Array.from({ length: 24 }, (_, i) => ({ })); /** - * 커스텀 드롭다운 컴포넌트 + * 커스텀 드롭다운 컴포넌트 (Portal 사용) */ function Dropdown({ value, options, onChange, placeholder = '선택', className = '' }) { const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); + const [position, setPosition] = useState({ top: 0, left: 0, width: 0 }); + const buttonRef = useRef(null); + const menuRef = useRef(null); useEffect(() => { const handleClickOutside = (event) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + if ( + buttonRef.current && + !buttonRef.current.contains(event.target) && + menuRef.current && + !menuRef.current.contains(event.target) + ) { setIsOpen(false); } }; @@ -53,11 +61,24 @@ function Dropdown({ value, options, onChange, placeholder = '선택', className return () => document.removeEventListener('mousedown', handleClickOutside); }, [isOpen]); + // 버튼 위치 계산 + useEffect(() => { + if (isOpen && buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setPosition({ + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + }); + } + }, [isOpen]); + const selectedOption = options.find((opt) => opt.value === value); return ( -
+ 키워드 중 하나라도 포함된 영상만 추가됩니다 +
++ 모든 영상에 선택한 멤버를 자동으로 연결합니다 +