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.title}

-
+
{schedule.time && (
- + {schedule.time.slice(0, 5)}
)} {categoryName && (
- {categoryName} + + {categoryName} + {schedule.source_name && ` · ${schedule.source_name}`} +
)}
+ {/* 멤버 태그 (별도 줄) */} + {(() => { + const memberList = schedule.members + ? schedule.members.map(m => m.name) + : (schedule.member_names ? schedule.member_names.split(',') : []); + if (memberList.length === 0) return null; + + // 5명 이상이면 '프로미스나인' 단일 태그 + if (memberList.length >= 5) { + return ( +
+ + 프로미스나인 + +
+ ); + } + + // 그 외에는 멤버별 개별 태그 + return ( +
+ {memberList.map((name, i) => ( + + {name} + + ))} +
+ ); + })()}
); diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx index 89e0b16..eab7a5e 100644 --- a/frontend/src/pages/pc/admin/AdminSchedule.jsx +++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx @@ -952,10 +952,33 @@ function AdminSchedule() { > {schedule.category_name || '미지정'} + {schedule.source_name && ( + + {schedule.source_name} + + )} {schedule.time?.slice(0, 5)}
-

{schedule.title}

-

{schedule.description}

+

{schedule.title}

+ {schedule.description && ( +

{schedule.description}

+ )} + {/* 멤버 태그 */} + {schedule.members && schedule.members.length > 0 && ( +
+ {schedule.members.length >= 5 ? ( + + 프로미스나인 + + ) : ( + schedule.members.map((member) => ( + + {member.name} + + )) + )} +
+ )} {/* 액션 버튼 */} diff --git a/frontend/src/pages/pc/admin/AdminScheduleBots.jsx b/frontend/src/pages/pc/admin/AdminScheduleBots.jsx index 84cb0ae..a686d42 100644 --- a/frontend/src/pages/pc/admin/AdminScheduleBots.jsx +++ b/frontend/src/pages/pc/admin/AdminScheduleBots.jsx @@ -63,7 +63,7 @@ function AdminScheduleBots() { }; // 봇 시작/정지 토글 - const toggleBot = async (botId, currentStatus) => { + const toggleBot = async (botId, currentStatus, botName) => { try { const token = localStorage.getItem('adminToken'); const action = currentStatus === 'running' ? 'stop' : 'start'; @@ -76,7 +76,7 @@ function AdminScheduleBots() { if (response.ok) { setToast({ type: 'success', - message: action === 'start' ? '봇이 시작되었습니다.' : '봇이 정지되었습니다.' + message: action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.` }); fetchBots(); // 목록 새로고침 } else { @@ -173,6 +173,19 @@ function AdminScheduleBots() { }); }; + // 간격 포맷 (분 → 분/시간/일) + const formatInterval = (minutes) => { + if (!minutes) return '-'; + if (minutes >= 1440) { + const days = Math.floor(minutes / 1440); + return `${days}일`; + } else if (minutes >= 60) { + const hours = Math.floor(minutes / 60); + return `${hours}시간`; + } + return `${minutes}분`; + }; + return (
setToast(null)} /> @@ -298,9 +311,7 @@ function AdminScheduleBots() {

- 채널: {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() { )}