From e759d14ed69467da4f2d8437c70a8cf2b74a7575 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 3 Feb 2026 14:36:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8A=A4=ED=94=84=20=EC=B1=84=EB=84=90?= =?UTF-8?q?=20=EB=8B=A4=EC=9D=8C=20=EC=A3=BC=20=EC=98=88=EC=A0=95=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 영상(쇼츠 제외) 추가 시 다음 주 같은 요일 예정 일정 자동 생성 - 실제 영상 업로드 시 예정 일정을 실제 정보로 덮어씌움 - 금요일 00시까지 영상 없으면 예정 일정 삭제 + 다음 주 예정 일정 생성 - autoScheduleNext 설정: dayOfWeek, time, title, deadlineDayOfWeek, excludeShorts Co-Authored-By: Claude Opus 4.5 --- backend/src/config/bots.js | 8 + backend/src/services/schedule.js | 24 ++- backend/src/services/youtube/index.js | 238 +++++++++++++++++++++++++- 3 files changed, 256 insertions(+), 14 deletions(-) diff --git a/backend/src/config/bots.js b/backend/src/config/bots.js index a99d3d6..aba0fa2 100644 --- a/backend/src/config/bots.js +++ b/backend/src/config/bots.js @@ -21,6 +21,14 @@ export default [ 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', diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index e1ed19a..43dabb3 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -39,14 +39,22 @@ export function buildDatetime(date, time) { export function buildSource(schedule) { const { category_id, youtube_video_id, youtube_video_type, youtube_channel, x_post_id } = schedule; - if (category_id === CATEGORY_IDS.YOUTUBE && youtube_video_id) { - const url = youtube_video_type === 'shorts' - ? `https://www.youtube.com/shorts/${youtube_video_id}` - : `https://www.youtube.com/watch?v=${youtube_video_id}`; - return { - name: youtube_channel || 'YouTube', - url, - }; + if (category_id === CATEGORY_IDS.YOUTUBE) { + if (youtube_video_id) { + const url = youtube_video_type === 'shorts' + ? `https://www.youtube.com/shorts/${youtube_video_id}` + : `https://www.youtube.com/watch?v=${youtube_video_id}`; + return { + name: youtube_channel || 'YouTube', + url, + }; + } else if (youtube_channel) { + // 예정 일정: video_id 없이 채널 이름만 + return { + name: youtube_channel, + url: null, + }; + } } if (category_id === CATEGORY_IDS.X && x_post_id) { diff --git a/backend/src/services/youtube/index.js b/backend/src/services/youtube/index.js index 614df21..b1e9e5e 100644 --- a/backend/src/services/youtube/index.js +++ b/backend/src/services/youtube/index.js @@ -3,7 +3,7 @@ import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.j import bots from '../../config/bots.js'; import { CATEGORY_IDS } from '../../config/index.js'; import { withTransaction } from '../../utils/transaction.js'; -import { syncScheduleById } from '../meilisearch/index.js'; +import { syncScheduleById, deleteSchedule } from '../meilisearch/index.js'; const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE; const PLAYLIST_CACHE_PREFIX = 'yt_uploads:'; @@ -28,6 +28,203 @@ async function youtubeBotPlugin(fastify, opts) { return playlistId; } + /** + * 다음 특정 요일 날짜 계산 (KST 기준) + * @param {number} targetDay - 목표 요일 (0=일, 4=목) + * @param {Date} fromDate - 기준 날짜 (기본: 오늘) + * @returns {string} YYYY-MM-DD 형식 + */ + function getNextWeekday(targetDay, fromDate = new Date()) { + const kst = new Date(fromDate.toLocaleString('en-US', { timeZone: 'Asia/Seoul' })); + const currentDay = kst.getDay(); + // 다음 주 같은 요일까지 일수 계산 + let daysUntil = targetDay - currentDay + 7; + if (daysUntil <= 0) daysUntil += 7; + + const nextDate = new Date(kst); + nextDate.setDate(kst.getDate() + daysUntil); + + const year = nextDate.getFullYear(); + const month = String(nextDate.getMonth() + 1).padStart(2, '0'); + const day = String(nextDate.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + /** + * 해당 날짜의 예정 일정 조회 (is_temp = 1인 것) + */ + async function findScheduledEntry(bot, date) { + const [rows] = await fastify.db.query( + `SELECT sy.schedule_id, s.title, s.date, s.time + FROM schedule_youtube sy + JOIN schedules s ON s.id = sy.schedule_id + WHERE s.is_temp = 1 AND sy.channel_id = ? AND s.date = ?`, + [bot.channelId, date] + ); + return rows[0] || null; + } + + /** + * 채널의 일반 영상 개수 조회 (쇼츠 제외) + */ + async function getVideoCount(channelId) { + const [rows] = await fastify.db.query( + `SELECT COUNT(*) as cnt FROM schedule_youtube + WHERE channel_id = ? AND video_type = 'video' AND video_id IS NOT NULL`, + [channelId] + ); + return rows[0].cnt; + } + + /** + * 예정 일정 제목 생성 + */ + async function generateScheduledTitle(bot) { + const { autoScheduleNext } = bot; + + if (autoScheduleNext.titleTemplate) { + const videoCount = await getVideoCount(bot.channelId); + const nextEpisode = videoCount + 1; + + return autoScheduleNext.titleTemplate + .replace('{channelName}', bot.channelName) + .replace('{episode}', nextEpisode); + } + + return autoScheduleNext.title || `${bot.channelName} (예정)`; + } + + /** + * 다음 주 예정 일정 생성 + */ + async function createScheduledEntry(bot) { + const { autoScheduleNext } = bot; + if (!autoScheduleNext) return null; + + const nextDate = getNextWeekday(autoScheduleNext.dayOfWeek); + + // 이미 존재하는지 확인 (같은 채널, 같은 날짜, is_temp = 1) + const [existing] = await fastify.db.query( + `SELECT sy.schedule_id FROM schedule_youtube sy + JOIN schedules s ON s.id = sy.schedule_id + WHERE s.is_temp = 1 AND sy.channel_id = ? AND s.date = ?`, + [bot.channelId, nextDate] + ); + if (existing.length > 0) { + return null; // 이미 존재 + } + + // 제목 생성 + const title = await generateScheduledTitle(bot); + + // 트랜잭션으로 생성 + const scheduleId = await withTransaction(fastify.db, async (conn) => { + const [result] = await conn.query( + 'INSERT INTO schedules (category_id, title, date, time, is_temp) VALUES (?, ?, ?, ?, 1)', + [YOUTUBE_CATEGORY_ID, title, nextDate, autoScheduleNext.time] + ); + const newScheduleId = result.insertId; + + await conn.query( + 'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)', + [newScheduleId, null, 'video', bot.channelId, bot.channelName] + ); + + return newScheduleId; + }); + + // Meilisearch 동기화 + if (scheduleId) { + await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); + fastify.log.info(`[${bot.id}] 다음 주 예정 일정 생성: ${nextDate} - ${title}`); + } + + return scheduleId; + } + + /** + * 예정 일정을 실제 영상으로 덮어씌움 + */ + async function updateScheduledEntry(scheduledEntry, video, bot) { + await withTransaction(fastify.db, async (conn) => { + // schedules 테이블 업데이트 (is_temp = 0으로 변경) + await conn.query( + 'UPDATE schedules SET title = ?, date = ?, time = ?, is_temp = 0 WHERE id = ?', + [video.title, video.date, video.time, scheduledEntry.schedule_id] + ); + + // schedule_youtube 테이블 업데이트 + await conn.query( + 'UPDATE schedule_youtube SET video_id = ?, video_type = ? WHERE schedule_id = ?', + [video.videoId, video.videoType, scheduledEntry.schedule_id] + ); + }); + + // Meilisearch 동기화 + await syncScheduleById(fastify.meilisearch, fastify.db, scheduledEntry.schedule_id); + fastify.log.info(`[${bot.id}] 예정 일정 업데이트: ${video.title}`); + + return scheduledEntry.schedule_id; + } + + /** + * 예정 일정 삭제 + 다음 주 예정 일정 생성 + */ + async function deleteScheduledAndCreateNext(bot, scheduleId) { + // 삭제 + await withTransaction(fastify.db, async (conn) => { + await conn.query('DELETE FROM schedule_members WHERE schedule_id = ?', [scheduleId]); + await conn.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [scheduleId]); + await conn.query('DELETE FROM schedules WHERE id = ?', [scheduleId]); + }); + + // Meilisearch에서도 삭제 + await deleteSchedule(fastify.meilisearch, scheduleId); + fastify.log.info(`[${bot.id}] 예정 일정 삭제 (영상 미업로드)`); + + // 다음 주 예정 일정 생성 + await createScheduledEntry(bot); + } + + /** + * 예정 일정 deadline 체크 (금요일 00시) + */ + async function checkScheduledDeadline(bot) { + const { autoScheduleNext } = bot; + if (!autoScheduleNext || !autoScheduleNext.deadlineDayOfWeek) return; + + const now = new Date(); + const kst = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' })); + const currentDay = kst.getDay(); + + // deadline 요일인지 확인 (금요일 = 5) + if (currentDay !== autoScheduleNext.deadlineDayOfWeek) { + return; + } + + // 어제(목요일) 날짜 계산 - deadline 당일이면 전날이 목표 요일 + const targetDate = new Date(kst); + targetDate.setDate(kst.getDate() - 1); // 어제 + + const year = targetDate.getFullYear(); + const month = String(targetDate.getMonth() + 1).padStart(2, '0'); + const day = String(targetDate.getDate()).padStart(2, '0'); + const targetDateStr = `${year}-${month}-${day}`; + + // 예정 일정이 아직 존재하는지 확인 (is_temp = 1인 것) + const [rows] = await fastify.db.query( + `SELECT sy.schedule_id FROM schedule_youtube sy + JOIN schedules s ON s.id = sy.schedule_id + WHERE s.is_temp = 1 AND sy.channel_id = ? AND s.date = ?`, + [bot.channelId, targetDateStr] + ); + + if (rows.length > 0) { + // 아직 예정 상태 → 삭제 + 다음 주 생성 + await deleteScheduledAndCreateNext(bot, rows[0].schedule_id); + } + } + /** * 멤버 이름 맵 조회 */ @@ -72,6 +269,23 @@ async function youtubeBotPlugin(fastify, opts) { return null; } + const { autoScheduleNext } = bot; + const isVideoType = video.videoType === 'video'; // 쇼츠가 아닌 일반 영상 + + // 예정 일정 처리 (쇼츠 제외 옵션이 있으면 쇼츠는 무시) + if (autoScheduleNext && isVideoType) { + // 해당 날짜의 예정 일정이 있는지 확인 + const scheduledEntry = await findScheduledEntry(bot, video.date); + + if (scheduledEntry) { + // 예정 일정을 실제 영상으로 덮어씌움 + await updateScheduledEntry(scheduledEntry, video, bot); + // 다음 주 예정 일정 생성 + await createScheduledEntry(bot); + return scheduledEntry.schedule_id; + } + } + // 멤버 이름 맵 미리 조회 (트랜잭션 전에) let nameMap = null; if (bot.extractMembersFromDesc) { @@ -79,18 +293,18 @@ async function youtubeBotPlugin(fastify, opts) { } // 트랜잭션으로 INSERT 작업 수행 - return withTransaction(fastify.db, async (connection) => { + const scheduleId = await withTransaction(fastify.db, async (connection) => { // schedules 테이블에 저장 const [result] = await connection.query( 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', [YOUTUBE_CATEGORY_ID, video.title, video.date, video.time] ); - const scheduleId = result.insertId; + const newScheduleId = result.insertId; // schedule_youtube 테이블에 저장 await connection.query( 'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)', - [scheduleId, video.videoId, video.videoType, video.channelId, bot.channelName] + [newScheduleId, video.videoId, video.videoType, video.channelId, bot.channelName] ); // 멤버 연결 (커스텀 설정) @@ -104,7 +318,7 @@ async function youtubeBotPlugin(fastify, opts) { } if (memberIds.length > 0) { const uniqueIds = [...new Set(memberIds)]; - const values = uniqueIds.map(id => [scheduleId, id]); + const values = uniqueIds.map(id => [newScheduleId, id]); await connection.query( 'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values] @@ -112,14 +326,26 @@ async function youtubeBotPlugin(fastify, opts) { } } - return scheduleId; + return newScheduleId; }); + + // 새 영상 추가 후 다음 주 예정 일정 생성 (쇼츠 제외) + if (autoScheduleNext && isVideoType && scheduleId) { + await createScheduledEntry(bot); + } + + return scheduleId; } /** * 최근 영상 동기화 (정기 실행) */ async function syncNewVideos(bot) { + // 예정 일정 deadline 체크 (금요일 00시) + if (bot.autoScheduleNext) { + await checkScheduledDeadline(bot); + } + const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId); const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId); let addedCount = 0;