feat(youtube-bot): 주간 지정 시간 폴링 모드 추가
- bot_youtube에 weekly_schedule_config JSON 컬럼 추가, cron_interval nullable로 변경 - weekly 모드: 지정 요일/시각에만 cron 트리거 → setInterval로 intervalSeconds 간격 폴링 - 종료 조건: 새 영상 1개 발견(stopOnFound) 또는 durationMinutes 경과 - 평상시 API 호출 없어 주 1회 업로드 채널(워크맨 등)의 할당량 낭비 최소화 - 프론트 폼에 상시/주간 모드 토글 추가, 요일 드롭다운 월~일 순서로 정렬 - 관련 문서(api/development/architecture) 갱신 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
39bb6f77f9
commit
f2a15e07d6
7 changed files with 371 additions and 122 deletions
|
|
@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS bot_youtube (
|
||||||
channel_handle VARCHAR(50),
|
channel_handle VARCHAR(50),
|
||||||
channel_name VARCHAR(100) NOT NULL,
|
channel_name VARCHAR(100) NOT NULL,
|
||||||
banner_url VARCHAR(500),
|
banner_url VARCHAR(500),
|
||||||
cron_interval INT DEFAULT 2,
|
cron_interval INT DEFAULT NULL,
|
||||||
enabled TINYINT(1) DEFAULT 1,
|
enabled TINYINT(1) DEFAULT 1,
|
||||||
|
|
||||||
-- 제목 필터 (선택, JSON 배열)
|
-- 제목 필터 (선택, JSON 배열)
|
||||||
|
|
@ -18,6 +18,11 @@ CREATE TABLE IF NOT EXISTS bot_youtube (
|
||||||
-- 다음 주 예정 일정 설정 (JSON)
|
-- 다음 주 예정 일정 설정 (JSON)
|
||||||
auto_schedule_config JSON,
|
auto_schedule_config JSON,
|
||||||
|
|
||||||
|
-- 주간 집중 폴링 설정 (JSON) — 있으면 cron_interval 대신 사용
|
||||||
|
-- { dayOfWeek: 0~6, startTime: "HH:MM", intervalSeconds: int, durationMinutes: int }
|
||||||
|
-- 새 영상 1개 발견 시 즉시 종료
|
||||||
|
weekly_schedule_config JSON,
|
||||||
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const MAX_CONSECUTIVE_ERRORS = 10;
|
||||||
|
|
||||||
async function schedulerPlugin(fastify, opts) {
|
async function schedulerPlugin(fastify, opts) {
|
||||||
const tasks = new Map();
|
const tasks = new Map();
|
||||||
|
const burstTimers = new Map(); // weekly 모드 내부 setInterval 핸들
|
||||||
let cachedBots = null;
|
let cachedBots = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,33 +21,51 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
const [rows] = await fastify.db.query(
|
const [rows] = await fastify.db.query(
|
||||||
'SELECT * FROM bot_youtube'
|
'SELECT * FROM bot_youtube'
|
||||||
);
|
);
|
||||||
return rows.map(row => ({
|
return rows.map(row => {
|
||||||
id: `youtube-${row.id}`, // DB ID를 문자열 형식으로 변환
|
const weekly = row.weekly_schedule_config
|
||||||
dbId: row.id,
|
? (typeof row.weekly_schedule_config === 'string'
|
||||||
type: 'youtube',
|
? JSON.parse(row.weekly_schedule_config)
|
||||||
channelId: row.channel_id,
|
: row.weekly_schedule_config)
|
||||||
channelHandle: row.channel_handle,
|
: null;
|
||||||
channelName: row.channel_name,
|
|
||||||
bannerUrl: row.banner_url,
|
// weekly 모드면 시작 시각에만 트리거, 아니면 cron_interval 분 주기
|
||||||
cron: `*/${row.cron_interval} * * * *`,
|
let cronExpr;
|
||||||
enabled: row.enabled === 1,
|
if (weekly && weekly.startTime && weekly.dayOfWeek !== undefined) {
|
||||||
titleFilters: row.title_filters
|
const [h, m] = weekly.startTime.split(':').map(Number);
|
||||||
? (typeof row.title_filters === 'string'
|
cronExpr = `${m} ${h} * * ${weekly.dayOfWeek}`;
|
||||||
? JSON.parse(row.title_filters)
|
} else {
|
||||||
: row.title_filters)
|
cronExpr = `*/${row.cron_interval || 2} * * * *`;
|
||||||
: [],
|
}
|
||||||
defaultMemberIds: row.default_member_ids
|
|
||||||
? (typeof row.default_member_ids === 'string'
|
return {
|
||||||
? JSON.parse(row.default_member_ids)
|
id: `youtube-${row.id}`, // DB ID를 문자열 형식으로 변환
|
||||||
: row.default_member_ids)
|
dbId: row.id,
|
||||||
: [],
|
type: 'youtube',
|
||||||
extractMembersFromDesc: row.extract_members_from_desc === 1,
|
channelId: row.channel_id,
|
||||||
autoScheduleNext: row.auto_schedule_config
|
channelHandle: row.channel_handle,
|
||||||
? (typeof row.auto_schedule_config === 'string'
|
channelName: row.channel_name,
|
||||||
? JSON.parse(row.auto_schedule_config)
|
bannerUrl: row.banner_url,
|
||||||
: row.auto_schedule_config)
|
cron: cronExpr,
|
||||||
: null,
|
enabled: row.enabled === 1,
|
||||||
}));
|
titleFilters: row.title_filters
|
||||||
|
? (typeof row.title_filters === 'string'
|
||||||
|
? JSON.parse(row.title_filters)
|
||||||
|
: row.title_filters)
|
||||||
|
: [],
|
||||||
|
defaultMemberIds: row.default_member_ids
|
||||||
|
? (typeof row.default_member_ids === 'string'
|
||||||
|
? JSON.parse(row.default_member_ids)
|
||||||
|
: row.default_member_ids)
|
||||||
|
: [],
|
||||||
|
extractMembersFromDesc: row.extract_members_from_desc === 1,
|
||||||
|
autoScheduleNext: row.auto_schedule_config
|
||||||
|
? (typeof row.auto_schedule_config === 'string'
|
||||||
|
? JSON.parse(row.auto_schedule_config)
|
||||||
|
: row.auto_schedule_config)
|
||||||
|
: null,
|
||||||
|
weeklySchedule: weekly,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -177,6 +196,102 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
invalidateCache();
|
invalidateCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 동기화 실행 + 에러 처리 (consecutiveErrors, 자동 정지 포함)
|
||||||
|
*/
|
||||||
|
async function runSync(botId, bot, syncFn, { setRunningStatus = false } = {}) {
|
||||||
|
try {
|
||||||
|
const result = await syncFn(bot);
|
||||||
|
const addedCount = await handleSyncResult(botId, result, { setRunningStatus });
|
||||||
|
fastify.log.info(`[${botId}] 동기화 완료: ${addedCount}개 추가`);
|
||||||
|
if (addedCount > 0) {
|
||||||
|
logActivity(fastify.db, {
|
||||||
|
actor: botId,
|
||||||
|
action: 'sync_complete',
|
||||||
|
category: 'sync',
|
||||||
|
summary: `${botId} 동기화 완료: ${addedCount}개 추가`,
|
||||||
|
details: { addedCount },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: true, addedCount };
|
||||||
|
} catch (err) {
|
||||||
|
const prev = await getStatus(botId);
|
||||||
|
const consecutiveErrors = (prev.consecutiveErrors || 0) + 1;
|
||||||
|
await updateStatus(botId, {
|
||||||
|
status: 'error',
|
||||||
|
lastCheckAt: nowKST(),
|
||||||
|
errorMessage: err.message,
|
||||||
|
consecutiveErrors,
|
||||||
|
});
|
||||||
|
fastify.log.error(`[${botId}] 동기화 오류 (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}): ${err.message}`);
|
||||||
|
if (consecutiveErrors === 1) {
|
||||||
|
logActivity(fastify.db, {
|
||||||
|
actor: botId,
|
||||||
|
action: 'error',
|
||||||
|
category: 'sync',
|
||||||
|
summary: `${botId} 동기화 오류: ${err.message}`,
|
||||||
|
details: { error: err.message },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||||
|
fastify.log.warn(`[${botId}] 연속 ${MAX_CONSECUTIVE_ERRORS}회 실패 - 자동 정지`);
|
||||||
|
logActivity(fastify.db, {
|
||||||
|
actor: botId,
|
||||||
|
action: 'stop',
|
||||||
|
category: 'bot',
|
||||||
|
summary: `${botId} 연속 ${MAX_CONSECUTIVE_ERRORS}회 실패로 자동 정지`,
|
||||||
|
details: { error: err.message, consecutiveErrors },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await stopBot(botId);
|
||||||
|
} catch (stopErr) {
|
||||||
|
fastify.log.error(`[${botId}] 자동 정지 실패: ${stopErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: false, err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주간 집중 폴링 세션 시작 (weekly 모드)
|
||||||
|
* 새 영상 1개 발견 시 즉시 종료, durationMinutes 초과 시도 종료
|
||||||
|
*/
|
||||||
|
async function startWeeklyBurst(botId, bot, syncFn) {
|
||||||
|
if (burstTimers.has(botId)) return; // 이미 실행 중이면 무시
|
||||||
|
|
||||||
|
const intervalSeconds = Math.max(5, bot.weeklySchedule?.intervalSeconds || 30);
|
||||||
|
const durationMinutes = Math.max(1, bot.weeklySchedule?.durationMinutes || 30);
|
||||||
|
const endAt = Date.now() + durationMinutes * 60 * 1000;
|
||||||
|
|
||||||
|
fastify.log.info(`[${botId}] 주간 폴링 시작 (간격 ${intervalSeconds}초, 최대 ${durationMinutes}분)`);
|
||||||
|
|
||||||
|
const stopBurst = (reason) => {
|
||||||
|
const handle = burstTimers.get(botId);
|
||||||
|
if (!handle) return;
|
||||||
|
clearInterval(handle.timer);
|
||||||
|
burstTimers.delete(botId);
|
||||||
|
fastify.log.info(`[${botId}] 주간 폴링 종료: ${reason}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (!burstTimers.has(botId)) return;
|
||||||
|
const result = await runSync(botId, bot, syncFn, { setRunningStatus: true });
|
||||||
|
if (!burstTimers.has(botId)) return; // runSync 중 자동 정지 등으로 정리됐을 수 있음
|
||||||
|
if (result.ok && result.addedCount > 0) {
|
||||||
|
stopBurst(`새 영상 ${result.addedCount}개 발견 (stopOnFound)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() >= endAt) {
|
||||||
|
stopBurst('최대 지속시간 초과');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타이머 먼저 등록 → tick에서 burstTimers.has 체크로 중복/중단 판별
|
||||||
|
const timer = setInterval(tick, intervalSeconds * 1000);
|
||||||
|
burstTimers.set(botId, { timer, endAt });
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 봇 시작
|
* 봇 시작
|
||||||
*/
|
*/
|
||||||
|
|
@ -191,6 +306,10 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
tasks.get(botId).stop();
|
tasks.get(botId).stop();
|
||||||
tasks.delete(botId);
|
tasks.delete(botId);
|
||||||
}
|
}
|
||||||
|
if (burstTimers.has(botId)) {
|
||||||
|
clearInterval(burstTimers.get(botId).timer);
|
||||||
|
burstTimers.delete(botId);
|
||||||
|
}
|
||||||
|
|
||||||
// DB enabled 활성화
|
// DB enabled 활성화
|
||||||
await setEnabled(botId, true);
|
await setEnabled(botId, true);
|
||||||
|
|
@ -203,55 +322,10 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
// cron 태스크 등록 (한국 시간 기준)
|
// cron 태스크 등록 (한국 시간 기준)
|
||||||
const task = cron.schedule(bot.cron, async () => {
|
const task = cron.schedule(bot.cron, async () => {
|
||||||
fastify.log.info(`[${botId}] 동기화 시작`);
|
fastify.log.info(`[${botId}] 동기화 시작`);
|
||||||
try {
|
if (bot.weeklySchedule) {
|
||||||
const result = await syncFn(bot);
|
await startWeeklyBurst(botId, bot, syncFn);
|
||||||
const addedCount = await handleSyncResult(botId, result, { setRunningStatus: true });
|
} else {
|
||||||
fastify.log.info(`[${botId}] 동기화 완료: ${addedCount}개 추가`);
|
await runSync(botId, bot, syncFn, { setRunningStatus: true });
|
||||||
if (addedCount > 0) {
|
|
||||||
logActivity(fastify.db, {
|
|
||||||
actor: botId,
|
|
||||||
action: 'sync_complete',
|
|
||||||
category: 'sync',
|
|
||||||
summary: `${botId} 동기화 완료: ${addedCount}개 추가`,
|
|
||||||
details: { addedCount },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const prev = await getStatus(botId);
|
|
||||||
const consecutiveErrors = (prev.consecutiveErrors || 0) + 1;
|
|
||||||
await updateStatus(botId, {
|
|
||||||
status: 'error',
|
|
||||||
lastCheckAt: nowKST(),
|
|
||||||
errorMessage: err.message,
|
|
||||||
consecutiveErrors,
|
|
||||||
});
|
|
||||||
fastify.log.error(`[${botId}] 동기화 오류 (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}): ${err.message}`);
|
|
||||||
// 첫 오류만 activity log에 기록 (중복 스팸 방지)
|
|
||||||
if (consecutiveErrors === 1) {
|
|
||||||
logActivity(fastify.db, {
|
|
||||||
actor: botId,
|
|
||||||
action: 'error',
|
|
||||||
category: 'sync',
|
|
||||||
summary: `${botId} 동기화 오류: ${err.message}`,
|
|
||||||
details: { error: err.message },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 임계값 도달 시 봇 자동 정지
|
|
||||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
||||||
fastify.log.warn(`[${botId}] 연속 ${MAX_CONSECUTIVE_ERRORS}회 실패 - 자동 정지`);
|
|
||||||
logActivity(fastify.db, {
|
|
||||||
actor: botId,
|
|
||||||
action: 'stop',
|
|
||||||
category: 'bot',
|
|
||||||
summary: `${botId} 연속 ${MAX_CONSECUTIVE_ERRORS}회 실패로 자동 정지`,
|
|
||||||
details: { error: err.message, consecutiveErrors },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await stopBot(botId);
|
|
||||||
} catch (stopErr) {
|
|
||||||
fastify.log.error(`[${botId}] 자동 정지 실패: ${stopErr.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, { timezone: TIMEZONE });
|
}, { timezone: TIMEZONE });
|
||||||
|
|
||||||
|
|
@ -259,31 +333,9 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
await updateStatus(botId, { status: 'running' });
|
await updateStatus(botId, { status: 'running' });
|
||||||
fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`);
|
fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`);
|
||||||
|
|
||||||
// 즉시 1회 실행 (meilisearch는 스케줄 시간에만 실행)
|
// 즉시 1회 실행: meilisearch와 weekly 모드는 제외 (weekly는 지정 시각에만)
|
||||||
if (bot.type !== 'meilisearch') {
|
if (bot.type !== 'meilisearch' && !bot.weeklySchedule) {
|
||||||
try {
|
await runSync(botId, bot, syncFn, { setRunningStatus: false });
|
||||||
const result = await syncFn(bot);
|
|
||||||
const addedCount = await handleSyncResult(botId, result);
|
|
||||||
fastify.log.info(`[${botId}] 초기 동기화 완료: ${addedCount}개 추가`);
|
|
||||||
if (addedCount > 0) {
|
|
||||||
logActivity(fastify.db, {
|
|
||||||
actor: botId,
|
|
||||||
action: 'sync_complete',
|
|
||||||
category: 'sync',
|
|
||||||
summary: `${botId} 초기 동기화 완료: ${addedCount}개 추가`,
|
|
||||||
details: { addedCount },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`);
|
|
||||||
logActivity(fastify.db, {
|
|
||||||
actor: botId,
|
|
||||||
action: 'error',
|
|
||||||
category: 'sync',
|
|
||||||
summary: `${botId} 초기 동기화 오류: ${err.message}`,
|
|
||||||
details: { error: err.message },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -295,6 +347,11 @@ async function schedulerPlugin(fastify, opts) {
|
||||||
tasks.get(botId).stop();
|
tasks.get(botId).stop();
|
||||||
tasks.delete(botId);
|
tasks.delete(botId);
|
||||||
}
|
}
|
||||||
|
// weekly 모드 burst 타이머도 정리
|
||||||
|
if (burstTimers.has(botId)) {
|
||||||
|
clearInterval(burstTimers.get(botId).timer);
|
||||||
|
burstTimers.delete(botId);
|
||||||
|
}
|
||||||
// DB enabled 비활성화
|
// DB enabled 비활성화
|
||||||
await setEnabled(botId, false);
|
await setEnabled(botId, false);
|
||||||
await updateStatus(botId, { status: 'stopped' });
|
await updateStatus(botId, { status: 'stopped' });
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,13 @@ const youtubeBotResponse = {
|
||||||
channel_handle: { type: 'string' },
|
channel_handle: { type: 'string' },
|
||||||
channel_name: { type: 'string' },
|
channel_name: { type: 'string' },
|
||||||
banner_url: { type: 'string' },
|
banner_url: { type: 'string' },
|
||||||
cron_interval: { type: 'integer' },
|
cron_interval: { type: ['integer', 'null'] },
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
title_filters: { type: 'array', items: { type: 'string' } },
|
title_filters: { type: 'array', items: { type: 'string' } },
|
||||||
default_member_ids: { type: 'array', items: { type: 'integer' } },
|
default_member_ids: { type: 'array', items: { type: 'integer' } },
|
||||||
extract_members_from_desc: { type: 'boolean' },
|
extract_members_from_desc: { type: 'boolean' },
|
||||||
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||||
|
weekly_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -59,6 +60,11 @@ function formatBotResponse(row) {
|
||||||
? JSON.parse(row.auto_schedule_config)
|
? JSON.parse(row.auto_schedule_config)
|
||||||
: row.auto_schedule_config)
|
: row.auto_schedule_config)
|
||||||
: null,
|
: null,
|
||||||
|
weekly_schedule_config: row.weekly_schedule_config
|
||||||
|
? (typeof row.weekly_schedule_config === 'string'
|
||||||
|
? JSON.parse(row.weekly_schedule_config)
|
||||||
|
: row.weekly_schedule_config)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,11 +182,12 @@ export default async function youtubeBotsRoutes(fastify) {
|
||||||
channel_handle: { type: ['string', 'null'] },
|
channel_handle: { type: ['string', 'null'] },
|
||||||
channel_name: { type: 'string' },
|
channel_name: { type: 'string' },
|
||||||
banner_url: { type: ['string', 'null'] },
|
banner_url: { type: ['string', 'null'] },
|
||||||
cron_interval: { type: 'integer', default: 2 },
|
cron_interval: { type: ['integer', 'null'] },
|
||||||
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||||
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
|
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
|
||||||
extract_members_from_desc: { type: 'boolean', default: false },
|
extract_members_from_desc: { type: 'boolean', default: false },
|
||||||
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||||
|
weekly_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||||
},
|
},
|
||||||
required: ['channel_id', 'channel_name'],
|
required: ['channel_id', 'channel_name'],
|
||||||
},
|
},
|
||||||
|
|
@ -196,11 +203,12 @@ export default async function youtubeBotsRoutes(fastify) {
|
||||||
channel_handle,
|
channel_handle,
|
||||||
channel_name,
|
channel_name,
|
||||||
banner_url,
|
banner_url,
|
||||||
cron_interval = 2,
|
cron_interval,
|
||||||
title_filters,
|
title_filters,
|
||||||
default_member_ids,
|
default_member_ids,
|
||||||
extract_members_from_desc = false,
|
extract_members_from_desc = false,
|
||||||
auto_schedule_config,
|
auto_schedule_config,
|
||||||
|
weekly_schedule_config,
|
||||||
} = request.body;
|
} = request.body;
|
||||||
|
|
||||||
// 중복 체크
|
// 중복 체크
|
||||||
|
|
@ -212,21 +220,26 @@ export default async function youtubeBotsRoutes(fastify) {
|
||||||
return badRequest(reply, '이미 등록된 채널입니다.');
|
return badRequest(reply, '이미 등록된 채널입니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// weekly 모드면 cron_interval은 무시(null 저장), 아니면 기본값 2
|
||||||
|
const finalCronInterval = weekly_schedule_config ? null : (cron_interval ?? 2);
|
||||||
|
|
||||||
const [result] = await db.query(
|
const [result] = await db.query(
|
||||||
`INSERT INTO bot_youtube
|
`INSERT INTO bot_youtube
|
||||||
(channel_id, channel_handle, channel_name, banner_url, cron_interval,
|
(channel_id, channel_handle, channel_name, banner_url, cron_interval,
|
||||||
title_filters, default_member_ids, extract_members_from_desc, auto_schedule_config, enabled)
|
title_filters, default_member_ids, extract_members_from_desc,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
|
auto_schedule_config, weekly_schedule_config, enabled)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||||
[
|
[
|
||||||
channel_id,
|
channel_id,
|
||||||
channel_handle || null,
|
channel_handle || null,
|
||||||
channel_name,
|
channel_name,
|
||||||
banner_url || null,
|
banner_url || null,
|
||||||
cron_interval,
|
finalCronInterval,
|
||||||
title_filters ? JSON.stringify(title_filters) : null,
|
title_filters ? JSON.stringify(title_filters) : null,
|
||||||
default_member_ids ? JSON.stringify(default_member_ids) : null,
|
default_member_ids ? JSON.stringify(default_member_ids) : null,
|
||||||
extract_members_from_desc ? 1 : 0,
|
extract_members_from_desc ? 1 : 0,
|
||||||
auto_schedule_config ? JSON.stringify(auto_schedule_config) : null,
|
auto_schedule_config ? JSON.stringify(auto_schedule_config) : null,
|
||||||
|
weekly_schedule_config ? JSON.stringify(weekly_schedule_config) : null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -261,11 +274,12 @@ export default async function youtubeBotsRoutes(fastify) {
|
||||||
channel_handle: { type: ['string', 'null'] },
|
channel_handle: { type: ['string', 'null'] },
|
||||||
channel_name: { type: 'string' },
|
channel_name: { type: 'string' },
|
||||||
banner_url: { type: ['string', 'null'] },
|
banner_url: { type: ['string', 'null'] },
|
||||||
cron_interval: { type: 'integer' },
|
cron_interval: { type: ['integer', 'null'] },
|
||||||
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||||
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
|
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
|
||||||
extract_members_from_desc: { type: 'boolean' },
|
extract_members_from_desc: { type: 'boolean' },
|
||||||
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||||
|
weekly_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -321,6 +335,15 @@ export default async function youtubeBotsRoutes(fastify) {
|
||||||
fields.push('auto_schedule_config = ?');
|
fields.push('auto_schedule_config = ?');
|
||||||
values.push(updates.auto_schedule_config ? JSON.stringify(updates.auto_schedule_config) : null);
|
values.push(updates.auto_schedule_config ? JSON.stringify(updates.auto_schedule_config) : null);
|
||||||
}
|
}
|
||||||
|
if (updates.weekly_schedule_config !== undefined) {
|
||||||
|
fields.push('weekly_schedule_config = ?');
|
||||||
|
values.push(updates.weekly_schedule_config ? JSON.stringify(updates.weekly_schedule_config) : null);
|
||||||
|
// weekly 모드로 전환하면 cron_interval은 null, 해제하면 기본값으로 복구(명시 cron_interval이 같이 오지 않은 경우)
|
||||||
|
if (updates.cron_interval === undefined) {
|
||||||
|
fields.push('cron_interval = ?');
|
||||||
|
values.push(updates.weekly_schedule_config ? null : 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (updates.enabled !== undefined) {
|
if (updates.enabled !== undefined) {
|
||||||
fields.push('enabled = ?');
|
fields.push('enabled = ?');
|
||||||
values.push(updates.enabled ? 1 : 0);
|
values.push(updates.enabled ? 1 : 0);
|
||||||
|
|
|
||||||
10
docs/api.md
10
docs/api.md
|
|
@ -336,10 +336,20 @@ YouTube 봇 추가
|
||||||
"time": "18:00:00",
|
"time": "18:00:00",
|
||||||
"titleTemplate": "{channelName} {episode}화",
|
"titleTemplate": "{channelName} {episode}화",
|
||||||
"deadlineDayOfWeek": 5
|
"deadlineDayOfWeek": 5
|
||||||
|
},
|
||||||
|
"weekly_schedule_config": {
|
||||||
|
"dayOfWeek": 3,
|
||||||
|
"startTime": "19:00",
|
||||||
|
"intervalSeconds": 30,
|
||||||
|
"durationMinutes": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**폴링 방식:**
|
||||||
|
- `cron_interval` (분): 상시 폴링. `weekly_schedule_config`가 null이면 이 값 사용
|
||||||
|
- `weekly_schedule_config`: 지정 요일/시각에만 집중 폴링. 값이 있으면 `cron_interval`은 무시(서버에서 null로 저장). 새 영상 1개 발견 시 즉시 종료(stopOnFound 기본), `durationMinutes` 초과 시에도 종료
|
||||||
|
|
||||||
### PUT /admin/youtube-bots/:id
|
### PUT /admin/youtube-bots/:id
|
||||||
YouTube 봇 수정
|
YouTube 봇 수정
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -327,7 +327,7 @@ fromis_9/
|
||||||
- `concert_setlist_members` - 셋리스트-멤버 연결
|
- `concert_setlist_members` - 셋리스트-멤버 연결
|
||||||
|
|
||||||
#### 봇
|
#### 봇
|
||||||
- `bot_youtube` - YouTube 봇 설정 (채널 정보, 동기화 간격, 필터 등, video_id UNIQUE)
|
- `bot_youtube` - YouTube 봇 설정 (채널 정보, 동기화 간격 또는 주간 지정 시간, 필터 등, video_id UNIQUE)
|
||||||
- `bot_x` - X 봇 설정 (username, 프로필, 동기화 간격, 텍스트 필터, 리트윗 포함, YouTube 추출)
|
- `bot_x` - X 봇 설정 (username, 프로필, 동기화 간격, 텍스트 필터, 리트윗 포함, YouTube 추출)
|
||||||
|
|
||||||
#### 활동 로그
|
#### 활동 로그
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,27 @@ queryClient.invalidateQueries();
|
||||||
- 새 영상 있을 때: 1 + 새 영상 수 units
|
- 새 영상 있을 때: 1 + 새 영상 수 units
|
||||||
- 1분 간격, 3채널 기준: ~4,320 units/일 (43%)
|
- 1분 간격, 3채널 기준: ~4,320 units/일 (43%)
|
||||||
|
|
||||||
|
### 폴링 모드 (bot_youtube)
|
||||||
|
|
||||||
|
두 가지 모드 중 하나를 선택 — 봇 레코드에 `cron_interval`(분) 또는 `weekly_schedule_config`(JSON) 중 하나가 채워짐.
|
||||||
|
|
||||||
|
**상시 폴링 (기본)**
|
||||||
|
- `cron_interval`이 분 단위로 지정됨. cron: `*/N * * * *`
|
||||||
|
- 매주 여러 날 업로드하는 채널에 적합 (예: `studio_fromis_9`)
|
||||||
|
|
||||||
|
**주간 지정 시간 (weekly)**
|
||||||
|
- `weekly_schedule_config: { dayOfWeek, startTime, intervalSeconds, durationMinutes }`
|
||||||
|
- 주 1회만 특정 요일·시각에 업로드되는 채널용 (예: 워크맨 매주 수 19:00)
|
||||||
|
- cron: `mm hh * * dayOfWeek` — 시작 시각 1회만 트리거
|
||||||
|
- 트리거 시 `startWeeklyBurst()`가 `setInterval`로 `intervalSeconds`마다 폴링
|
||||||
|
- **종료 조건** (둘 중 먼저):
|
||||||
|
1. 새 영상 1개 발견 (stopOnFound, 기본 동작)
|
||||||
|
2. `durationMinutes` 경과
|
||||||
|
- 평상시에는 API 호출 없음 → 할당량 최소화
|
||||||
|
- `burstTimers` Map에서 봇 ID별 내부 타이머 추적, `stopBot()`에서 같이 정리
|
||||||
|
|
||||||
|
두 모드 모두 `MAX_CONSECUTIVE_ERRORS` (기본 10회) 자동 정지 로직이 공통 적용됨.
|
||||||
|
|
||||||
### 주요 API 함수 (services/youtube/api.js)
|
### 주요 API 함수 (services/youtube/api.js)
|
||||||
| 함수 | YouTube API | 용도 |
|
| 함수 | YouTube API | 용도 |
|
||||||
|------|-----------|------|
|
|------|-----------|------|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Youtube, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-reac
|
||||||
import { getMembers } from '@/api/public/members';
|
import { getMembers } from '@/api/public/members';
|
||||||
import { getYouTubeBot, createYouTubeBot, updateYouTubeBot, lookupChannel } from '@/api/admin/bots';
|
import { getYouTubeBot, createYouTubeBot, updateYouTubeBot, lookupChannel } from '@/api/admin/bots';
|
||||||
|
|
||||||
// 동기화 간격 옵션
|
// 동기화 간격 옵션 (분)
|
||||||
const INTERVAL_OPTIONS = [
|
const INTERVAL_OPTIONS = [
|
||||||
{ value: 1, label: '1분' },
|
{ value: 1, label: '1분' },
|
||||||
{ value: 2, label: '2분' },
|
{ value: 2, label: '2분' },
|
||||||
|
|
@ -19,15 +19,32 @@ const INTERVAL_OPTIONS = [
|
||||||
{ value: 60, label: '1시간' },
|
{ value: 60, label: '1시간' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 요일 옵션
|
// weekly 모드 폴링 간격 옵션 (초)
|
||||||
|
const WEEKLY_INTERVAL_OPTIONS = [
|
||||||
|
{ value: 10, label: '10초' },
|
||||||
|
{ value: 30, label: '30초' },
|
||||||
|
{ value: 60, label: '1분' },
|
||||||
|
{ value: 120, label: '2분' },
|
||||||
|
{ value: 300, label: '5분' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// weekly 모드 지속 시간 옵션 (분)
|
||||||
|
const WEEKLY_DURATION_OPTIONS = [
|
||||||
|
{ value: 10, label: '10분' },
|
||||||
|
{ value: 30, label: '30분' },
|
||||||
|
{ value: 60, label: '1시간' },
|
||||||
|
{ value: 120, label: '2시간' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 요일 옵션 (월~일 순서 표시, value는 cron 표준 0=일 ~ 6=토 유지)
|
||||||
const DAY_OPTIONS = [
|
const DAY_OPTIONS = [
|
||||||
{ value: 0, label: '일요일' },
|
|
||||||
{ value: 1, label: '월요일' },
|
{ value: 1, label: '월요일' },
|
||||||
{ value: 2, label: '화요일' },
|
{ value: 2, label: '화요일' },
|
||||||
{ value: 3, label: '수요일' },
|
{ value: 3, label: '수요일' },
|
||||||
{ value: 4, label: '목요일' },
|
{ value: 4, label: '목요일' },
|
||||||
{ value: 5, label: '금요일' },
|
{ value: 5, label: '금요일' },
|
||||||
{ value: 6, label: '토요일' },
|
{ value: 6, label: '토요일' },
|
||||||
|
{ value: 0, label: '일요일' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 시간 옵션 (00:00 ~ 23:00)
|
// 시간 옵션 (00:00 ~ 23:00)
|
||||||
|
|
@ -262,7 +279,12 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
const [handle, setHandle] = useState('');
|
const [handle, setHandle] = useState('');
|
||||||
const [channelInfo, setChannelInfo] = useState(null);
|
const [channelInfo, setChannelInfo] = useState(null);
|
||||||
const [lookupLoading, setLookupLoading] = useState(false);
|
const [lookupLoading, setLookupLoading] = useState(false);
|
||||||
|
const [pollingMode, setPollingMode] = useState('interval'); // 'interval' | 'weekly'
|
||||||
const [interval, setInterval] = useState(2);
|
const [interval, setInterval] = useState(2);
|
||||||
|
const [weeklyDayOfWeek, setWeeklyDayOfWeek] = useState(1); // 기본 월요일
|
||||||
|
const [weeklyStartTime, setWeeklyStartTime] = useState('00:00');
|
||||||
|
const [weeklyIntervalSeconds, setWeeklyIntervalSeconds] = useState(30);
|
||||||
|
const [weeklyDurationMinutes, setWeeklyDurationMinutes] = useState(30);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
// 예정 일정 설정
|
// 예정 일정 설정
|
||||||
|
|
@ -315,6 +337,26 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
});
|
});
|
||||||
setInterval(bot.cron_interval || 2);
|
setInterval(bot.cron_interval || 2);
|
||||||
|
|
||||||
|
// 폴링 모드 판별: weekly_schedule_config가 있으면 weekly
|
||||||
|
const weeklyCfg = bot.weekly_schedule_config
|
||||||
|
? (typeof bot.weekly_schedule_config === 'string'
|
||||||
|
? JSON.parse(bot.weekly_schedule_config)
|
||||||
|
: bot.weekly_schedule_config)
|
||||||
|
: null;
|
||||||
|
if (weeklyCfg) {
|
||||||
|
setPollingMode('weekly');
|
||||||
|
setWeeklyDayOfWeek(weeklyCfg.dayOfWeek ?? 1);
|
||||||
|
setWeeklyStartTime(weeklyCfg.startTime || '00:00');
|
||||||
|
setWeeklyIntervalSeconds(weeklyCfg.intervalSeconds ?? 30);
|
||||||
|
setWeeklyDurationMinutes(weeklyCfg.durationMinutes ?? 30);
|
||||||
|
} else {
|
||||||
|
setPollingMode('interval');
|
||||||
|
setWeeklyDayOfWeek(1);
|
||||||
|
setWeeklyStartTime('00:00');
|
||||||
|
setWeeklyIntervalSeconds(30);
|
||||||
|
setWeeklyDurationMinutes(30);
|
||||||
|
}
|
||||||
|
|
||||||
const config = bot.auto_schedule_config
|
const config = bot.auto_schedule_config
|
||||||
? (typeof bot.auto_schedule_config === 'string'
|
? (typeof bot.auto_schedule_config === 'string'
|
||||||
? JSON.parse(bot.auto_schedule_config)
|
? JSON.parse(bot.auto_schedule_config)
|
||||||
|
|
@ -353,6 +395,11 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
setHandle('');
|
setHandle('');
|
||||||
setChannelInfo(null);
|
setChannelInfo(null);
|
||||||
setInterval(2);
|
setInterval(2);
|
||||||
|
setPollingMode('interval');
|
||||||
|
setWeeklyDayOfWeek(1);
|
||||||
|
setWeeklyStartTime('00:00');
|
||||||
|
setWeeklyIntervalSeconds(30);
|
||||||
|
setWeeklyDurationMinutes(30);
|
||||||
setAutoScheduleEnabled(false);
|
setAutoScheduleEnabled(false);
|
||||||
setScheduleDayOfWeek(4);
|
setScheduleDayOfWeek(4);
|
||||||
setScheduleTime('18:00');
|
setScheduleTime('18:00');
|
||||||
|
|
@ -396,7 +443,7 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
const data = {
|
const data = {
|
||||||
channel_handle: handle || null,
|
channel_handle: handle || null,
|
||||||
channel_name: channelInfo.title,
|
channel_name: channelInfo.title,
|
||||||
cron_interval: interval,
|
cron_interval: pollingMode === 'interval' ? interval : null,
|
||||||
title_filters: titleFilters.length > 0 ? titleFilters : null,
|
title_filters: titleFilters.length > 0 ? titleFilters : null,
|
||||||
default_member_ids: defaultMemberIds.length > 0 ? defaultMemberIds : null,
|
default_member_ids: defaultMemberIds.length > 0 ? defaultMemberIds : null,
|
||||||
extract_members_from_desc: extractMembers,
|
extract_members_from_desc: extractMembers,
|
||||||
|
|
@ -408,6 +455,14 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
deadlineDayOfWeek,
|
deadlineDayOfWeek,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
weekly_schedule_config: pollingMode === 'weekly'
|
||||||
|
? {
|
||||||
|
dayOfWeek: weeklyDayOfWeek,
|
||||||
|
startTime: weeklyStartTime,
|
||||||
|
intervalSeconds: weeklyIntervalSeconds,
|
||||||
|
durationMinutes: weeklyDurationMinutes,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
|
|
@ -530,17 +585,95 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 동기화 간격 */}
|
{/* 동기화 모드 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
동기화 간격
|
동기화 방식
|
||||||
</label>
|
</label>
|
||||||
<Dropdown
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||||
value={interval}
|
<button
|
||||||
options={INTERVAL_OPTIONS}
|
type="button"
|
||||||
onChange={setInterval}
|
onClick={() => setPollingMode('interval')}
|
||||||
placeholder="간격 선택"
|
className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
/>
|
pollingMode === 'interval'
|
||||||
|
? 'bg-red-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
상시 폴링
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPollingMode('weekly')}
|
||||||
|
className={`px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
pollingMode === 'weekly'
|
||||||
|
? 'bg-red-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
주간 지정 시간
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pollingMode === 'interval' ? (
|
||||||
|
<div>
|
||||||
|
<Dropdown
|
||||||
|
value={interval}
|
||||||
|
options={INTERVAL_OPTIONS}
|
||||||
|
onChange={setInterval}
|
||||||
|
placeholder="간격 선택"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
선택한 간격으로 계속 체크합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">요일</label>
|
||||||
|
<Dropdown
|
||||||
|
value={weeklyDayOfWeek}
|
||||||
|
options={DAY_OPTIONS}
|
||||||
|
onChange={setWeeklyDayOfWeek}
|
||||||
|
placeholder="요일 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">시작 시각</label>
|
||||||
|
<Dropdown
|
||||||
|
value={weeklyStartTime}
|
||||||
|
options={TIME_OPTIONS}
|
||||||
|
onChange={setWeeklyStartTime}
|
||||||
|
placeholder="시간 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">폴링 간격</label>
|
||||||
|
<Dropdown
|
||||||
|
value={weeklyIntervalSeconds}
|
||||||
|
options={WEEKLY_INTERVAL_OPTIONS}
|
||||||
|
onChange={setWeeklyIntervalSeconds}
|
||||||
|
placeholder="간격 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-600 mb-1">최대 지속</label>
|
||||||
|
<Dropdown
|
||||||
|
value={weeklyDurationMinutes}
|
||||||
|
options={WEEKLY_DURATION_OPTIONS}
|
||||||
|
onChange={setWeeklyDurationMinutes}
|
||||||
|
placeholder="지속 시간"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
지정된 요일·시각부터 이 간격으로 폴링합니다. 새 영상 발견 시 즉시 종료하며, 최대 지속시간 초과 시에도 종료합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 예정 일정 자동 생성 */}
|
{/* 예정 일정 자동 생성 */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue