feat: 스프 채널 다음 주 예정 일정 자동 생성 기능
- 새 영상(쇼츠 제외) 추가 시 다음 주 같은 요일 예정 일정 자동 생성 - 실제 영상 업로드 시 예정 일정을 실제 정보로 덮어씌움 - 금요일 00시까지 영상 없으면 예정 일정 삭제 + 다음 주 예정 일정 생성 - autoScheduleNext 설정: dayOfWeek, time, title, deadlineDayOfWeek, excludeShorts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9735206da7
commit
e759d14ed6
3 changed files with 256 additions and 14 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue