diff --git a/backend/routes/admin.js b/backend/routes/admin.js index c4330c1..ca6b513 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1176,7 +1176,7 @@ router.get("/schedules", async (req, res) => { const [schedules] = await pool.query( `SELECT s.id, s.title, s.date, s.time, s.end_date, s.end_time, - s.category_id, s.description, s.source_url, + s.category_id, s.description, s.source_url, s.source_name, s.location_name, s.location_address, s.location_detail, s.location_lat, s.location_lng, s.created_at, c.name as category_name, c.color as category_color @@ -1233,6 +1233,7 @@ router.post( category, description, url, + sourceName, members, locationName, locationAddress, @@ -1249,9 +1250,9 @@ router.post( // 일정 삽입 const [scheduleResult] = await connection.query( `INSERT INTO schedules - (title, date, time, end_date, end_time, category_id, description, source_url, + (title, date, time, end_date, end_time, category_id, description, source_url, source_name, location_name, location_address, location_detail, location_lat, location_lng) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ title, date, @@ -1261,6 +1262,7 @@ router.post( category || null, description || null, url || null, + sourceName || null, locationName || null, locationAddress || null, locationDetail || null, @@ -1426,6 +1428,7 @@ router.put( category, description, url, + sourceName, members, locationName, locationAddress, @@ -1451,6 +1454,7 @@ router.put( category_id = ?, description = ?, source_url = ?, + source_name = ?, location_name = ?, location_address = ?, location_detail = ?, @@ -1466,6 +1470,7 @@ router.put( category || null, description || null, url || null, + sourceName || null, locationName || null, locationAddress || null, locationDetail || null, @@ -1662,7 +1667,12 @@ router.delete("/schedules/:id", authenticateToken, async (req, res) => { // 봇 목록 조회 router.get("/bots", authenticateToken, async (req, res) => { try { - const [bots] = await pool.query(`SELECT * FROM bots ORDER BY id ASC`); + const [bots] = await pool.query(` + SELECT b.*, c.channel_id, c.rss_url, c.channel_name + FROM bots b + LEFT JOIN bot_youtube_config c ON b.id = c.bot_id + ORDER BY b.id ASC + `); res.json(bots); } catch (error) { console.error("봇 목록 조회 오류:", error); diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js index 6a7c124..efe619c 100644 --- a/backend/routes/schedules.js +++ b/backend/routes/schedules.js @@ -15,11 +15,16 @@ router.get("/", async (req, res) => { s.time, s.category_id, s.source_url, + s.source_name, s.location_name, c.name as category_name, - c.color as category_color + c.color as category_color, + GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id + LEFT JOIN schedule_members sm ON s.id = sm.schedule_id + LEFT JOIN members m ON sm.member_id = m.id + GROUP BY s.id ORDER BY s.date DESC, s.time DESC `); diff --git a/backend/server.js b/backend/server.js index 5540e52..49ab759 100644 --- a/backend/server.js +++ b/backend/server.js @@ -6,6 +6,7 @@ import albumsRouter from "./routes/albums.js"; import statsRouter from "./routes/stats.js"; import adminRouter from "./routes/admin.js"; import schedulesRouter from "./routes/schedules.js"; +import { initScheduler } from "./services/youtube-scheduler.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -40,6 +41,14 @@ app.get("*", (req, res) => { res.sendFile(path.join(__dirname, "dist", "index.html")); }); -app.listen(PORT, () => { +app.listen(PORT, async () => { console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`); + + // YouTube 봇 스케줄러 초기화 + try { + await initScheduler(); + console.log("📺 YouTube 봇 스케줄러 초기화 완료"); + } catch (error) { + console.error("YouTube 스케줄러 초기화 오류:", error); + } }); diff --git a/backend/services/youtube-bot.js b/backend/services/youtube-bot.js index 3797f7c..dafcdc1 100644 --- a/backend/services/youtube-bot.js +++ b/backend/services/youtube-bot.js @@ -5,12 +5,37 @@ import pool from "../lib/db.js"; const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; -// RSS 파서 설정 +// 봇별 커스텀 설정 (DB 대신 코드에서 관리) +// botId를 키로 사용 +const BOT_CUSTOM_CONFIG = { + // MUSINSA TV: 제목에 '성수기' 포함된 영상만, 이채영 기본 멤버, description에서 멤버 추출 + 3: { + titleFilter: "성수기", + defaultMemberId: 7, // 이채영 + extractMembersFromDesc: true, + }, +}; + +/** + * 봇 커스텀 설정 조회 + */ +function getBotCustomConfig(botId) { + return ( + BOT_CUSTOM_CONFIG[botId] || { + titleFilter: null, + defaultMemberId: null, + extractMembersFromDesc: false, + } + ); +} + +// RSS 파서 설정 (media:description 포함) const rssParser = new Parser({ customFields: { item: [ ["yt:videoId", "videoId"], ["yt:channelId", "channelId"], + ["media:group", "mediaGroup"], ], }, }); @@ -89,9 +114,16 @@ export async function parseRSSFeed(rssUrl) { const videoType = getVideoType(link); const publishedAt = toKST(new Date(item.pubDate)); + // media:group에서 description 추출 + let description = ""; + if (item.mediaGroup && item.mediaGroup["media:description"]) { + description = item.mediaGroup["media:description"][0] || ""; + } + return { videoId, title: item.title, + description, publishedAt, date: formatDate(publishedAt), time: formatTime(publishedAt), @@ -186,6 +218,7 @@ export async function fetchAllVideosFromAPI(channelId) { videos.push({ videoId, title: snippet.title, + description: snippet.description || "", publishedAt, date: formatDate(publishedAt), time: formatTime(publishedAt), @@ -209,8 +242,17 @@ export async function fetchAllVideosFromAPI(channelId) { /** * 영상을 일정으로 추가 (source_url로 중복 체크) + * @param {Object} video - 영상 정보 + * @param {number} categoryId - 카테고리 ID + * @param {number[]} memberIds - 연결할 멤버 ID 배열 (선택) + * @param {string} sourceName - 출처 이름 (선택) */ -export async function createScheduleFromVideo(video, categoryId) { +export async function createScheduleFromVideo( + video, + categoryId, + memberIds = [], + sourceName = null +) { try { // source_url로 중복 체크 const [existing] = await pool.query( @@ -224,25 +266,82 @@ export async function createScheduleFromVideo(video, categoryId) { // 일정 생성 const [result] = await pool.query( - `INSERT INTO schedules (title, date, time, category_id, source_url) - VALUES (?, ?, ?, ?, ?)`, - [video.title, video.date, video.time, categoryId, video.videoUrl] + `INSERT INTO schedules (title, date, time, category_id, source_url, source_name) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + video.title, + video.date, + video.time, + categoryId, + video.videoUrl, + sourceName, + ] ); - return result.insertId; + const scheduleId = result.insertId; + + // 멤버 연결 + if (memberIds.length > 0) { + const uniqueMemberIds = [...new Set(memberIds)]; // 중복 제거 + const memberValues = uniqueMemberIds.map((memberId) => [ + scheduleId, + memberId, + ]); + await pool.query( + `INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`, + [memberValues] + ); + } + + return scheduleId; } catch (error) { console.error("일정 생성 오류:", error); throw error; } } +/** + * 멤버 이름 목록 조회 + */ +async function getMemberNameMap() { + const [members] = await pool.query("SELECT id, name FROM members"); + const nameMap = {}; + for (const m of members) { + nameMap[m.name] = m.id; + } + return nameMap; +} + +/** + * description에서 멤버 이름 추출 + */ +function extractMemberIdsFromDescription(description, memberNameMap) { + if (!description) return []; + + const memberIds = []; + for (const [name, id] of Object.entries(memberNameMap)) { + if (description.includes(name)) { + memberIds.push(id); + } + } + return memberIds; +} + /** * 봇의 새 영상 동기화 (RSS 기반) */ export async function syncNewVideos(botId) { try { - // 봇 정보 조회 (bots 테이블에서 직접) - const [bots] = await pool.query(`SELECT * FROM bots WHERE id = ?`, [botId]); + // 봇 정보 조회 (bot_youtube_config 조인) + const [bots] = await pool.query( + ` + SELECT b.*, c.channel_id, c.rss_url + FROM bots b + LEFT JOIN bot_youtube_config c ON b.id = c.bot_id + WHERE b.id = ? + `, + [botId] + ); if (bots.length === 0) { throw new Error("봇을 찾을 수 없습니다."); @@ -254,16 +353,53 @@ export async function syncNewVideos(botId) { throw new Error("RSS URL이 설정되지 않았습니다."); } + // 봇별 커스텀 설정 조회 + const customConfig = getBotCustomConfig(botId); + const categoryId = await getYoutubeCategory(); // RSS 피드 파싱 const videos = await parseRSSFeed(bot.rss_url); let addedCount = 0; - for (const video of videos) { - // Shorts도 포함하여 일정으로 추가 + // 멤버 추출을 위한 이름 맵 조회 (필요 시) + let memberNameMap = null; + if (customConfig.extractMembersFromDesc) { + memberNameMap = await getMemberNameMap(); + } - const scheduleId = await createScheduleFromVideo(video, categoryId); + for (const video of videos) { + // 제목 필터 적용 (설정된 경우) + if ( + customConfig.titleFilter && + !video.title.includes(customConfig.titleFilter) + ) { + continue; // 필터에 맞지 않으면 스킵 + } + + // 멤버 ID 수집 + const memberIds = []; + + // 기본 멤버 추가 + if (customConfig.defaultMemberId) { + memberIds.push(customConfig.defaultMemberId); + } + + // description에서 멤버 추출 (설정된 경우) + if (customConfig.extractMembersFromDesc && memberNameMap) { + const extractedIds = extractMemberIdsFromDescription( + video.description, + memberNameMap + ); + memberIds.push(...extractedIds); + } + + const scheduleId = await createScheduleFromVideo( + video, + categoryId, + memberIds, + bot.name + ); if (scheduleId) { addedCount++; } @@ -299,8 +435,16 @@ export async function syncNewVideos(botId) { */ export async function syncAllVideos(botId) { try { - // 봇 정보 조회 (bots 테이블에서 직접) - const [bots] = await pool.query(`SELECT * FROM bots WHERE id = ?`, [botId]); + // 봇 정보 조회 (bot_youtube_config 조인) + const [bots] = await pool.query( + ` + SELECT b.*, c.channel_id, c.rss_url + FROM bots b + LEFT JOIN bot_youtube_config c ON b.id = c.bot_id + WHERE b.id = ? + `, + [botId] + ); if (bots.length === 0) { throw new Error("봇을 찾을 수 없습니다."); @@ -312,16 +456,53 @@ export async function syncAllVideos(botId) { throw new Error("Channel ID가 설정되지 않았습니다."); } + // 봇별 커스텀 설정 조회 + const customConfig = getBotCustomConfig(botId); + const categoryId = await getYoutubeCategory(); // API로 전체 영상 수집 const videos = await fetchAllVideosFromAPI(bot.channel_id); let addedCount = 0; - for (const video of videos) { - // Shorts도 포함하여 일정으로 추가 + // 멤버 추출을 위한 이름 맵 조회 (필요 시) + let memberNameMap = null; + if (customConfig.extractMembersFromDesc) { + memberNameMap = await getMemberNameMap(); + } - const scheduleId = await createScheduleFromVideo(video, categoryId); + for (const video of videos) { + // 제목 필터 적용 (설정된 경우) + if ( + customConfig.titleFilter && + !video.title.includes(customConfig.titleFilter) + ) { + continue; // 필터에 맞지 않으면 스킵 + } + + // 멤버 ID 수집 + const memberIds = []; + + // 기본 멤버 추가 + if (customConfig.defaultMemberId) { + memberIds.push(customConfig.defaultMemberId); + } + + // description에서 멤버 추출 (설정된 경우) + if (customConfig.extractMembersFromDesc && memberNameMap) { + const extractedIds = extractMemberIdsFromDescription( + video.description, + memberNameMap + ); + memberIds.push(...extractedIds); + } + + const scheduleId = await createScheduleFromVideo( + video, + categoryId, + memberIds, + bot.name + ); if (scheduleId) { addedCount++; } diff --git a/backend/services/youtube-scheduler.js b/backend/services/youtube-scheduler.js index c6b5aef..353b829 100644 --- a/backend/services/youtube-scheduler.js +++ b/backend/services/youtube-scheduler.js @@ -5,6 +5,13 @@ import { syncNewVideos } from "./youtube-bot.js"; // 봇별 스케줄러 인스턴스 저장 const schedulers = new Map(); +/** + * 봇이 메모리에서 실행 중인지 확인 + */ +export function isBotRunning(botId) { + return schedulers.has(botId); +} + /** * 개별 봇 스케줄 등록 */ @@ -40,6 +47,30 @@ export function unregisterBot(botId) { } } +/** + * 10초 간격으로 메모리 상태와 DB status 동기화 + */ +async function syncBotStatuses() { + try { + const [bots] = await pool.query("SELECT id, status FROM bots"); + + for (const bot of bots) { + const isRunningInMemory = schedulers.has(bot.id); + const isRunningInDB = bot.status === "running"; + + // 메모리에 없는데 DB가 running이면 → 서버 크래시 등으로 불일치, DB를 stopped로 업데이트 + if (!isRunningInMemory && isRunningInDB) { + await pool.query("UPDATE bots SET status = 'stopped' WHERE id = ?", [ + bot.id, + ]); + console.log(`[Scheduler] Bot ${bot.id} 상태 동기화: stopped`); + } + } + } catch (error) { + console.error("[Scheduler] 상태 동기화 오류:", error.message); + } +} + /** * 서버 시작 시 실행 중인 봇들 스케줄 등록 */ @@ -54,6 +85,10 @@ export async function initScheduler() { } console.log(`[Scheduler] ${bots.length}개 봇 스케줄 등록됨`); + + // 10초 간격으로 상태 동기화 (DB status와 메모리 상태 일치 유지) + setInterval(syncBotStatuses, 10000); + console.log(`[Scheduler] 10초 간격 상태 동기화 시작`); } catch (error) { console.error("[Scheduler] 초기화 오류:", error); } @@ -102,4 +137,5 @@ export default { unregisterBot, startBot, stopBot, + isBotRunning, }; diff --git a/frontend/src/pages/pc/Schedule.jsx b/frontend/src/pages/pc/Schedule.jsx index 10abd29..3ae3fbe 100644 --- a/frontend/src/pages/pc/Schedule.jsx +++ b/frontend/src/pages/pc/Schedule.jsx @@ -726,23 +726,55 @@ function Schedule() {
{schedule.description}
+{schedule.description}
+ )} + {/* 멤버 태그 */} + {schedule.members && schedule.members.length > 0 && ( +- 채널: {bot.channel_name || bot.channel_id} | - {bot.include_shorts ? ' Shorts 포함' : ' Shorts 제외'} | - {bot.check_interval}분 간격 + 채널: {bot.channel_name || bot.channel_id} | {formatInterval(bot.check_interval)} 간격
{/* 메타 정보 */} @@ -343,7 +354,7 @@ function AdminScheduleBots() { )}