diff --git a/backend/package-lock.json b/backend/package-lock.json index 8eb1140..652a0c3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,8 @@ "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", "mysql2": "^3.11.0", + "node-cron": "^4.2.1", + "rss-parser": "^3.13.0", "sharp": "^0.33.5" } }, @@ -2339,6 +2341,15 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2933,6 +2944,15 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -3071,6 +3091,16 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/rss-parser": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", + "integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==", + "license": "MIT", + "dependencies": { + "entities": "^2.0.3", + "xml2js": "^0.5.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3097,6 +3127,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", + "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", + "license": "BlueOak-1.0.0" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -3405,6 +3441,28 @@ "node": ">= 0.8" } }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 6cc2474..1b1b676 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,8 @@ "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", "mysql2": "^3.11.0", + "node-cron": "^4.2.1", + "rss-parser": "^3.13.0", "sharp": "^0.33.5" } -} \ No newline at end of file +} diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 8dfdc22..a01ceeb 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -9,6 +9,8 @@ import { DeleteObjectCommand, } from "@aws-sdk/client-s3"; import pool from "../lib/db.js"; +import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js"; +import { startBot, stopBot } from "../services/youtube-scheduler.js"; const router = express.Router(); @@ -1145,20 +1147,32 @@ router.put( // 일정 목록 조회 router.get("/schedules", async (req, res) => { try { - const { year, month } = req.query; + const { year, month, search } = req.query; - let whereClause = ""; + let whereConditions = []; let params = []; - // 년/월 필터링 - if (year && month) { - whereClause = "WHERE YEAR(s.date) = ? AND MONTH(s.date) = ?"; - params = [parseInt(year), parseInt(month)]; - } else if (year) { - whereClause = "WHERE YEAR(s.date) = ?"; - params = [parseInt(year)]; + // 검색어가 있으면 전체 일정에서 검색 (년/월 필터 무시) + if (search && search.trim()) { + const searchTerm = `%${search.trim()}%`; + whereConditions.push("(s.title LIKE ? OR s.description LIKE ?)"); + params.push(searchTerm, searchTerm); + } else { + // 년/월 필터링 (검색이 아닐 때만) + if (year && month) { + whereConditions.push("YEAR(s.date) = ? AND MONTH(s.date) = ?"); + params.push(parseInt(year), parseInt(month)); + } else if (year) { + whereConditions.push("YEAR(s.date) = ?"); + params.push(parseInt(year)); + } } + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + const [schedules] = await pool.query( `SELECT s.id, s.title, s.date, s.time, s.end_date, s.end_time, @@ -1641,4 +1655,70 @@ router.delete("/schedules/:id", authenticateToken, async (req, res) => { } }); +// ===================================================== +// YouTube 봇 API +// ===================================================== + +// 봇 목록 조회 +router.get("/bots", authenticateToken, async (req, res) => { + try { + const [bots] = await pool.query( + `SELECT b.*, c.channel_id, c.channel_name, c.rss_url, c.include_shorts + FROM bots b + LEFT JOIN bot_youtube_config c ON b.id = c.bot_id + ORDER BY b.created_at DESC` + ); + res.json(bots); + } catch (error) { + console.error("봇 목록 조회 오류:", error); + res.status(500).json({ error: "봇 목록 조회 중 오류가 발생했습니다." }); + } +}); + +// 봇 시작 +router.post("/bots/:id/start", authenticateToken, async (req, res) => { + try { + const { id } = req.params; + await startBot(id); + res.json({ message: "봇이 시작되었습니다." }); + } catch (error) { + console.error("봇 시작 오류:", error); + res + .status(500) + .json({ error: error.message || "봇 시작 중 오류가 발생했습니다." }); + } +}); + +// 봇 정지 +router.post("/bots/:id/stop", authenticateToken, async (req, res) => { + try { + const { id } = req.params; + await stopBot(id); + res.json({ message: "봇이 정지되었습니다." }); + } catch (error) { + console.error("봇 정지 오류:", error); + res + .status(500) + .json({ error: error.message || "봇 정지 중 오류가 발생했습니다." }); + } +}); + +// 전체 동기화 (초기화) +router.post("/bots/:id/sync-all", authenticateToken, async (req, res) => { + try { + const { id } = req.params; + const result = await syncAllVideos(id); + res.json({ + message: `${result.addedCount}개 일정이 추가되었습니다.`, + addedCount: result.addedCount, + total: result.total, + }); + } catch (error) { + console.error("전체 동기화 오류:", error); + res + .status(500) + .json({ error: error.message || "전체 동기화 중 오류가 발생했습니다." }); + } +}); + export default router; diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js new file mode 100644 index 0000000..6a7c124 --- /dev/null +++ b/backend/routes/schedules.js @@ -0,0 +1,78 @@ +import express from "express"; +import pool from "../lib/db.js"; + +const router = express.Router(); + +// 공개 일정 목록 조회 +router.get("/", async (req, res) => { + try { + const [schedules] = await pool.query(` + SELECT + s.id, + s.title, + s.description, + s.date, + s.time, + s.category_id, + s.source_url, + s.location_name, + c.name as category_name, + c.color as category_color + FROM schedules s + LEFT JOIN schedule_categories c ON s.category_id = c.id + ORDER BY s.date DESC, s.time DESC + `); + + res.json(schedules); + } catch (error) { + console.error("일정 목록 조회 오류:", error); + res.status(500).json({ error: "일정 목록 조회 중 오류가 발생했습니다." }); + } +}); + +// 카테고리 목록 조회 +router.get("/categories", async (req, res) => { + try { + const [categories] = await pool.query(` + SELECT id, name, color, sort_order + FROM schedule_categories + ORDER BY sort_order ASC + `); + + res.json(categories); + } catch (error) { + console.error("카테고리 조회 오류:", error); + res.status(500).json({ error: "카테고리 조회 중 오류가 발생했습니다." }); + } +}); + +// 개별 일정 조회 +router.get("/:id", async (req, res) => { + try { + const { id } = req.params; + + const [schedules] = await pool.query( + ` + SELECT + s.*, + c.name as category_name, + c.color as category_color + FROM schedules s + LEFT JOIN schedule_categories c ON s.category_id = c.id + WHERE s.id = ? + `, + [id] + ); + + if (schedules.length === 0) { + return res.status(404).json({ error: "일정을 찾을 수 없습니다." }); + } + + res.json(schedules[0]); + } catch (error) { + console.error("일정 조회 오류:", error); + res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." }); + } +}); + +export default router; diff --git a/backend/server.js b/backend/server.js index e22faa7..5540e52 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,6 +5,7 @@ import membersRouter from "./routes/members.js"; import albumsRouter from "./routes/albums.js"; import statsRouter from "./routes/stats.js"; import adminRouter from "./routes/admin.js"; +import schedulesRouter from "./routes/schedules.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -27,6 +28,12 @@ app.use("/api/members", membersRouter); app.use("/api/albums", albumsRouter); app.use("/api/stats", statsRouter); app.use("/api/admin", adminRouter); +app.use("/api/schedules", schedulesRouter); +app.use("/api/schedule-categories", (req, res, next) => { + // /api/schedule-categories -> /api/schedules/categories로 리다이렉트 + req.url = "/categories"; + schedulesRouter(req, res, next); +}); // SPA 폴백 - 모든 요청을 index.html로 app.get("*", (req, res) => { diff --git a/backend/services/youtube-bot.js b/backend/services/youtube-bot.js new file mode 100644 index 0000000..6b0b985 --- /dev/null +++ b/backend/services/youtube-bot.js @@ -0,0 +1,368 @@ +import Parser from "rss-parser"; +import pool from "../lib/db.js"; + +// YouTube API 키 +const YOUTUBE_API_KEY = + process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; + +// RSS 파서 설정 +const rssParser = new Parser({ + customFields: { + item: [ + ["yt:videoId", "videoId"], + ["yt:channelId", "channelId"], + ], + }, +}); + +/** + * UTC → KST 변환 + */ +export function toKST(utcDate) { + const date = new Date(utcDate); + return new Date(date.getTime() + 9 * 60 * 60 * 1000); +} + +/** + * 날짜를 YYYY-MM-DD 형식으로 변환 + */ +export function formatDate(date) { + return date.toISOString().split("T")[0]; +} + +/** + * 시간을 HH:MM:SS 형식으로 변환 + */ +export function formatTime(date) { + return date.toTimeString().split(" ")[0]; +} + +/** + * '유튜브' 카테고리 ID 조회 (없으면 생성) + */ +export async function getYoutubeCategory() { + const [rows] = await pool.query( + "SELECT id FROM schedule_categories WHERE name = '유튜브'" + ); + + if (rows.length > 0) { + return rows[0].id; + } + + // 없으면 생성 + const [result] = await pool.query( + "INSERT INTO schedule_categories (name, color, sort_order) VALUES ('유튜브', '#ff0033', 99)" + ); + return result.insertId; +} + +/** + * 영상 URL에서 유형 판별 (video/shorts) + */ +export function getVideoType(url) { + if (url.includes("/shorts/")) { + return "shorts"; + } + return "video"; +} + +/** + * 영상 URL 생성 + */ +export function getVideoUrl(videoId, videoType) { + if (videoType === "shorts") { + return `https://www.youtube.com/shorts/${videoId}`; + } + return `https://www.youtube.com/watch?v=${videoId}`; +} + +/** + * RSS 피드 파싱하여 영상 목록 반환 + */ +export async function parseRSSFeed(rssUrl) { + try { + const feed = await rssParser.parseURL(rssUrl); + + return feed.items.map((item) => { + const videoId = item.videoId; + const link = item.link || ""; + const videoType = getVideoType(link); + const publishedAt = toKST(new Date(item.pubDate)); + + return { + videoId, + title: item.title, + publishedAt, + date: formatDate(publishedAt), + time: formatTime(publishedAt), + videoUrl: link || getVideoUrl(videoId, videoType), + videoType, + }; + }); + } catch (error) { + console.error("RSS 파싱 오류:", error); + throw error; + } +} + +/** + * ISO 8601 duration (PT1M30S) → 초 변환 + */ +function parseDuration(duration) { + const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return 0; + + const hours = parseInt(match[1] || 0); + const minutes = parseInt(match[2] || 0); + const seconds = parseInt(match[3] || 0); + + return hours * 3600 + minutes * 60 + seconds; +} + +/** + * YouTube API로 전체 영상 수집 (초기 동기화용) + * Shorts 판별: duration이 60초 이하이면 Shorts + */ +export async function fetchAllVideosFromAPI(channelId) { + const videos = []; + let pageToken = ""; + + try { + // 채널의 업로드 플레이리스트 ID 조회 + const channelResponse = await fetch( + `https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${channelId}&key=${YOUTUBE_API_KEY}` + ); + const channelData = await channelResponse.json(); + + if (!channelData.items || channelData.items.length === 0) { + throw new Error("채널을 찾을 수 없습니다."); + } + + const uploadsPlaylistId = + channelData.items[0].contentDetails.relatedPlaylists.uploads; + + // 플레이리스트 아이템 조회 (페이징) + do { + const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${uploadsPlaylistId}&maxResults=50&key=${YOUTUBE_API_KEY}${ + pageToken ? `&pageToken=${pageToken}` : "" + }`; + + const response = await fetch(url); + const data = await response.json(); + + if (data.error) { + throw new Error(data.error.message); + } + + // 영상 ID 목록 추출 + const videoIds = data.items.map( + (item) => item.snippet.resourceId.videoId + ); + + // videos API로 duration 조회 (50개씩 배치) + const videosResponse = await fetch( + `https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds.join( + "," + )}&key=${YOUTUBE_API_KEY}` + ); + const videosData = await videosResponse.json(); + + // duration으로 Shorts 판별 맵 생성 + const durationMap = {}; + if (videosData.items) { + for (const v of videosData.items) { + const duration = v.contentDetails.duration; + const seconds = parseDuration(duration); + durationMap[v.id] = seconds <= 60 ? "shorts" : "video"; + } + } + + for (const item of data.items) { + const snippet = item.snippet; + const videoId = snippet.resourceId.videoId; + const publishedAt = toKST(new Date(snippet.publishedAt)); + const videoType = durationMap[videoId] || "video"; + + videos.push({ + videoId, + title: snippet.title, + publishedAt, + date: formatDate(publishedAt), + time: formatTime(publishedAt), + videoUrl: getVideoUrl(videoId, videoType), + videoType, + }); + } + + pageToken = data.nextPageToken || ""; + } while (pageToken); + + // 과거순 정렬 (오래된 영상부터 추가) + videos.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt)); + + return videos; + } catch (error) { + console.error("YouTube API 오류:", error); + throw error; + } +} + +/** + * 영상을 일정으로 추가 (source_url로 중복 체크) + */ +export async function createScheduleFromVideo(video, categoryId) { + try { + // source_url로 중복 체크 + const [existing] = await pool.query( + "SELECT id FROM schedules WHERE source_url = ?", + [video.videoUrl] + ); + + if (existing.length > 0) { + return null; // 이미 존재 + } + + // 일정 생성 + 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] + ); + + return result.insertId; + } catch (error) { + console.error("일정 생성 오류:", error); + throw error; + } +} + +/** + * 봇의 새 영상 동기화 (RSS 기반) + */ +export async function syncNewVideos(botId) { + try { + // 봇 정보 조회 + const [bots] = await pool.query( + `SELECT b.*, c.channel_id, c.rss_url, c.include_shorts + FROM bots b + JOIN bot_youtube_config c ON b.id = c.bot_id + WHERE b.id = ?`, + [botId] + ); + + if (bots.length === 0) { + throw new Error("봇을 찾을 수 없습니다."); + } + + const bot = bots[0]; + const categoryId = await getYoutubeCategory(); + + // RSS 피드 파싱 + const videos = await parseRSSFeed(bot.rss_url); + let addedCount = 0; + + for (const video of videos) { + // Shorts 필터링 + if (!bot.include_shorts && video.videoType === "shorts") { + continue; + } + + const scheduleId = await createScheduleFromVideo(video, categoryId); + if (scheduleId) { + addedCount++; + } + } + + // 봇 상태 업데이트 + await pool.query( + `UPDATE bots SET + last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), + schedules_added = schedules_added + ?, + error_message = NULL + WHERE id = ?`, + [addedCount, botId] + ); + + return { addedCount, total: videos.length }; + } catch (error) { + // 오류 상태 업데이트 + await pool.query( + `UPDATE bots SET + last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), + status = 'error', + error_message = ? + WHERE id = ?`, + [error.message, botId] + ); + throw error; + } +} + +/** + * 전체 영상 동기화 (API 기반, 초기화용) + */ +export async function syncAllVideos(botId) { + try { + // 봇 정보 조회 + const [bots] = await pool.query( + `SELECT b.*, c.channel_id, c.include_shorts + FROM bots b + JOIN bot_youtube_config c ON b.id = c.bot_id + WHERE b.id = ?`, + [botId] + ); + + if (bots.length === 0) { + throw new Error("봇을 찾을 수 없습니다."); + } + + const bot = bots[0]; + const categoryId = await getYoutubeCategory(); + + // API로 전체 영상 수집 + const videos = await fetchAllVideosFromAPI(bot.channel_id); + let addedCount = 0; + + for (const video of videos) { + // Shorts 필터링 + if (!bot.include_shorts && video.videoType === "shorts") { + continue; + } + + const scheduleId = await createScheduleFromVideo(video, categoryId); + if (scheduleId) { + addedCount++; + } + } + + // 봇 상태 업데이트 + await pool.query( + `UPDATE bots SET + last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), + schedules_added = schedules_added + ?, + error_message = NULL + WHERE id = ?`, + [addedCount, botId] + ); + + return { addedCount, total: videos.length }; + } catch (error) { + await pool.query( + `UPDATE bots SET + status = 'error', + error_message = ? + WHERE id = ?`, + [error.message, botId] + ); + throw error; + } +} + +export default { + parseRSSFeed, + fetchAllVideosFromAPI, + syncNewVideos, + syncAllVideos, + getYoutubeCategory, + toKST, +}; diff --git a/backend/services/youtube-scheduler.js b/backend/services/youtube-scheduler.js new file mode 100644 index 0000000..57679e9 --- /dev/null +++ b/backend/services/youtube-scheduler.js @@ -0,0 +1,107 @@ +import cron from "node-cron"; +import pool from "../lib/db.js"; +import { syncNewVideos } from "./youtube-bot.js"; + +// 봇별 스케줄러 인스턴스 저장 +const schedulers = new Map(); + +/** + * 개별 봇 스케줄 등록 + */ +export function registerBot(botId, intervalMinutes = 2) { + // 기존 스케줄이 있으면 제거 + unregisterBot(botId); + + // cron 표현식 생성 (1분 시작, X분 간격: 1,3,5,7...) + const cronExpression = `1-59/${intervalMinutes} * * * *`; + + const task = cron.schedule(cronExpression, async () => { + console.log(`[Bot ${botId}] 동기화 시작...`); + try { + const result = await syncNewVideos(botId); + console.log(`[Bot ${botId}] 동기화 완료: ${result.addedCount}개 추가`); + } catch (error) { + console.error(`[Bot ${botId}] 동기화 오류:`, error.message); + } + }); + + schedulers.set(botId, task); + console.log( + `[Bot ${botId}] 스케줄 등록됨 (${intervalMinutes}분 간격, 1분 오프셋)` + ); +} + +/** + * 개별 봇 스케줄 해제 + */ +export function unregisterBot(botId) { + if (schedulers.has(botId)) { + schedulers.get(botId).stop(); + schedulers.delete(botId); + console.log(`[Bot ${botId}] 스케줄 해제됨`); + } +} + +/** + * 서버 시작 시 실행 중인 봇들 스케줄 등록 + */ +export async function initScheduler() { + try { + const [bots] = await pool.query( + "SELECT id, check_interval FROM bots WHERE status = 'running'" + ); + + for (const bot of bots) { + registerBot(bot.id, bot.check_interval); + } + + console.log(`[Scheduler] ${bots.length}개 봇 스케줄 등록됨`); + } catch (error) { + console.error("[Scheduler] 초기화 오류:", error); + } +} + +/** + * 봇 시작 + */ +export async function startBot(botId) { + const [bots] = await pool.query("SELECT * FROM bots WHERE id = ?", [botId]); + if (bots.length === 0) { + throw new Error("봇을 찾을 수 없습니다."); + } + + const bot = bots[0]; + + // 스케줄 등록 + registerBot(botId, bot.check_interval); + + // 상태 업데이트 + await pool.query( + "UPDATE bots SET status = 'running', error_message = NULL WHERE id = ?", + [botId] + ); + + // 즉시 1회 실행 + try { + await syncNewVideos(botId); + } catch (error) { + console.error(`[Bot ${botId}] 초기 동기화 오류:`, error.message); + } +} + +/** + * 봇 정지 + */ +export async function stopBot(botId) { + unregisterBot(botId); + + await pool.query("UPDATE bots SET status = 'stopped' WHERE id = ?", [botId]); +} + +export default { + initScheduler, + registerBot, + unregisterBot, + startBot, + stopBot, +}; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5ba44a8..088111c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,6 +20,7 @@ import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos'; import AdminSchedule from './pages/pc/admin/AdminSchedule'; import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm'; import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory'; +import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots'; // PC 레이아웃 import PCLayout from './components/pc/Layout'; @@ -42,6 +43,7 @@ function App() { } /> } /> } /> + } /> {/* 일반 페이지 (레이아웃 포함) */} { + if (toast) { + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + } + }, [toast]); + + useEffect(() => { + const token = localStorage.getItem('adminToken'); + const userData = localStorage.getItem('adminUser'); + + if (!token || !userData) { + navigate('/admin'); + return; + } + + setUser(JSON.parse(userData)); + fetchBots(); + }, [navigate]); + + // 봇 목록 조회 + const fetchBots = async () => { + try { + const token = localStorage.getItem('adminToken'); + const response = await fetch('/api/admin/bots', { + headers: { Authorization: `Bearer ${token}` } + }); + + if (response.ok) { + const data = await response.json(); + setBots(data); + } + } catch (error) { + console.error('봇 목록 조회 오류:', error); + setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' }); + } finally { + setLoading(false); + } + }; + + const handleLogout = () => { + localStorage.removeItem('adminToken'); + localStorage.removeItem('adminUser'); + navigate('/admin'); + }; + + // 봇 시작/정지 토글 + const toggleBot = async (botId, currentStatus) => { + try { + const token = localStorage.getItem('adminToken'); + const action = currentStatus === 'running' ? 'stop' : 'start'; + + const response = await fetch(`/api/admin/bots/${botId}/${action}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` } + }); + + if (response.ok) { + setToast({ + type: 'success', + message: action === 'start' ? '봇이 시작되었습니다.' : '봇이 정지되었습니다.' + }); + fetchBots(); // 목록 새로고침 + } else { + const data = await response.json(); + setToast({ type: 'error', message: data.error || '작업 실패' }); + } + } catch (error) { + console.error('봇 토글 오류:', error); + setToast({ type: 'error', message: '작업 중 오류가 발생했습니다.' }); + } + }; + + // 전체 동기화 + const syncAllVideos = async (botId) => { + setSyncing(botId); + try { + const token = localStorage.getItem('adminToken'); + const response = await fetch(`/api/admin/bots/${botId}/sync-all`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` } + }); + + if (response.ok) { + const data = await response.json(); + setToast({ + type: 'success', + message: `${data.addedCount}개 일정이 추가되었습니다. (전체 ${data.total}개)` + }); + fetchBots(); + } else { + const data = await response.json(); + setToast({ type: 'error', message: data.error || '동기화 실패' }); + } + } catch (error) { + console.error('전체 동기화 오류:', error); + setToast({ type: 'error', message: '동기화 중 오류가 발생했습니다.' }); + } finally { + setSyncing(null); + } + }; + + + // 상태 아이콘 및 색상 + const getStatusInfo = (status) => { + switch (status) { + case 'running': + return { + icon: , + text: '실행 중', + color: 'text-green-500', + bg: 'bg-green-50', + dot: 'bg-green-500', + }; + case 'stopped': + return { + icon: , + text: '정지됨', + color: 'text-gray-400', + bg: 'bg-gray-50', + dot: 'bg-gray-400', + }; + case 'error': + return { + icon: , + text: '오류', + color: 'text-red-500', + bg: 'bg-red-50', + dot: 'bg-red-500', + }; + default: + return { + icon: null, + text: '알 수 없음', + color: 'text-gray-400', + bg: 'bg-gray-50', + dot: 'bg-gray-400', + }; + } + }; + + // 시간 포맷 (DB에 KST로 저장되어 있으므로 그대로 표시) + const formatTime = (dateString) => { + if (!dateString) return '-'; + // DB의 KST 시간을 UTC로 재해석하지 않도록 Z 접미사 제거 + const cleanDateString = dateString.replace('Z', '').replace('T', ' '); + const date = new Date(cleanDateString); + return date.toLocaleString('ko-KR', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + return ( +
+ setToast(null)} /> + + {/* 헤더 */} +
+
+
+ + fromis_9 + + + Admin + +
+
+ + 안녕하세요, {user?.username}님 + + +
+
+
+ + {/* 메인 콘텐츠 */} +
+ {/* 브레드크럼 */} +
+ + + + + + 일정 관리 + + + 봇 관리 +
+ + {/* 타이틀 */} +
+

봇 관리

+

일정 자동화 봇을 관리합니다

+
+ + {/* 봇 통계 */} +
+
+
전체 봇
+
{bots.length}
+
+
+
실행 중
+
+ {bots.filter(b => b.status === 'running').length} +
+
+
+
정지됨
+
+ {bots.filter(b => b.status === 'stopped').length} +
+
+
+
오류
+
+ {bots.filter(b => b.status === 'error').length} +
+
+
+ + {/* 봇 목록 */} +
+
+

봇 목록

+
+ + {loading ? ( +
+
+
+ ) : bots.length === 0 ? ( +
+ +

등록된 봇이 없습니다

+

위의 버튼을 클릭하여 봇을 추가하세요

+
+ ) : ( +
+ {bots.map((bot, index) => { + const statusInfo = getStatusInfo(bot.status); + + return ( + +
+ {/* 아이콘 */} +
+ +
+ + {/* 정보 */} +
+
+

{bot.name}

+ + + {statusInfo.text} + + + YouTube + +
+

+ 채널: {bot.channel_name || bot.channel_id} | + {bot.include_shorts ? ' Shorts 포함' : ' Shorts 제외'} | + {bot.check_interval}분 간격 +

+ + {/* 메타 정보 */} +
+ + + 마지막 체크: {formatTime(bot.last_check_at)} + + + + 추가된 일정: {bot.schedules_added}개 + +
+ + {/* 오류 메시지 */} + {bot.status === 'error' && bot.error_message && ( +
+ ⚠️ {bot.error_message} +
+ )} +
+ + {/* 액션 버튼 */} +
+ + +
+
+
+ ); + })} +
+ )} +
+
+
+ ); +} + +export default AdminScheduleBots;