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_name VARCHAR(100) NOT NULL,
|
||||
banner_url VARCHAR(500),
|
||||
cron_interval INT DEFAULT 2,
|
||||
cron_interval INT DEFAULT NULL,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
|
||||
-- 제목 필터 (선택, JSON 배열)
|
||||
|
|
@ -18,6 +18,11 @@ CREATE TABLE IF NOT EXISTS bot_youtube (
|
|||
-- 다음 주 예정 일정 설정 (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,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const MAX_CONSECUTIVE_ERRORS = 10;
|
|||
|
||||
async function schedulerPlugin(fastify, opts) {
|
||||
const tasks = new Map();
|
||||
const burstTimers = new Map(); // weekly 모드 내부 setInterval 핸들
|
||||
let cachedBots = null;
|
||||
|
||||
/**
|
||||
|
|
@ -20,33 +21,51 @@ async function schedulerPlugin(fastify, opts) {
|
|||
const [rows] = await fastify.db.query(
|
||||
'SELECT * FROM bot_youtube'
|
||||
);
|
||||
return rows.map(row => ({
|
||||
id: `youtube-${row.id}`, // DB ID를 문자열 형식으로 변환
|
||||
dbId: row.id,
|
||||
type: 'youtube',
|
||||
channelId: row.channel_id,
|
||||
channelHandle: row.channel_handle,
|
||||
channelName: row.channel_name,
|
||||
bannerUrl: row.banner_url,
|
||||
cron: `*/${row.cron_interval} * * * *`,
|
||||
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,
|
||||
}));
|
||||
return rows.map(row => {
|
||||
const weekly = row.weekly_schedule_config
|
||||
? (typeof row.weekly_schedule_config === 'string'
|
||||
? JSON.parse(row.weekly_schedule_config)
|
||||
: row.weekly_schedule_config)
|
||||
: null;
|
||||
|
||||
// weekly 모드면 시작 시각에만 트리거, 아니면 cron_interval 분 주기
|
||||
let cronExpr;
|
||||
if (weekly && weekly.startTime && weekly.dayOfWeek !== undefined) {
|
||||
const [h, m] = weekly.startTime.split(':').map(Number);
|
||||
cronExpr = `${m} ${h} * * ${weekly.dayOfWeek}`;
|
||||
} else {
|
||||
cronExpr = `*/${row.cron_interval || 2} * * * *`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `youtube-${row.id}`, // DB ID를 문자열 형식으로 변환
|
||||
dbId: row.id,
|
||||
type: 'youtube',
|
||||
channelId: row.channel_id,
|
||||
channelHandle: row.channel_handle,
|
||||
channelName: row.channel_name,
|
||||
bannerUrl: row.banner_url,
|
||||
cron: cronExpr,
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 동기화 실행 + 에러 처리 (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.delete(botId);
|
||||
}
|
||||
if (burstTimers.has(botId)) {
|
||||
clearInterval(burstTimers.get(botId).timer);
|
||||
burstTimers.delete(botId);
|
||||
}
|
||||
|
||||
// DB enabled 활성화
|
||||
await setEnabled(botId, true);
|
||||
|
|
@ -203,55 +322,10 @@ async function schedulerPlugin(fastify, opts) {
|
|||
// cron 태스크 등록 (한국 시간 기준)
|
||||
const task = cron.schedule(bot.cron, async () => {
|
||||
fastify.log.info(`[${botId}] 동기화 시작`);
|
||||
try {
|
||||
const result = await syncFn(bot);
|
||||
const addedCount = await handleSyncResult(botId, result, { setRunningStatus: true });
|
||||
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) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
if (bot.weeklySchedule) {
|
||||
await startWeeklyBurst(botId, bot, syncFn);
|
||||
} else {
|
||||
await runSync(botId, bot, syncFn, { setRunningStatus: true });
|
||||
}
|
||||
}, { timezone: TIMEZONE });
|
||||
|
||||
|
|
@ -259,31 +333,9 @@ async function schedulerPlugin(fastify, opts) {
|
|||
await updateStatus(botId, { status: 'running' });
|
||||
fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`);
|
||||
|
||||
// 즉시 1회 실행 (meilisearch는 스케줄 시간에만 실행)
|
||||
if (bot.type !== 'meilisearch') {
|
||||
try {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
// 즉시 1회 실행: meilisearch와 weekly 모드는 제외 (weekly는 지정 시각에만)
|
||||
if (bot.type !== 'meilisearch' && !bot.weeklySchedule) {
|
||||
await runSync(botId, bot, syncFn, { setRunningStatus: false });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,6 +347,11 @@ async function schedulerPlugin(fastify, opts) {
|
|||
tasks.get(botId).stop();
|
||||
tasks.delete(botId);
|
||||
}
|
||||
// weekly 모드 burst 타이머도 정리
|
||||
if (burstTimers.has(botId)) {
|
||||
clearInterval(burstTimers.get(botId).timer);
|
||||
burstTimers.delete(botId);
|
||||
}
|
||||
// DB enabled 비활성화
|
||||
await setEnabled(botId, false);
|
||||
await updateStatus(botId, { status: 'stopped' });
|
||||
|
|
|
|||
|
|
@ -14,12 +14,13 @@ const youtubeBotResponse = {
|
|||
channel_handle: { type: 'string' },
|
||||
channel_name: { type: 'string' },
|
||||
banner_url: { type: 'string' },
|
||||
cron_interval: { type: 'integer' },
|
||||
cron_interval: { type: ['integer', 'null'] },
|
||||
enabled: { type: 'boolean' },
|
||||
title_filters: { type: 'array', items: { type: 'string' } },
|
||||
default_member_ids: { type: 'array', items: { type: 'integer' } },
|
||||
extract_members_from_desc: { type: 'boolean' },
|
||||
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)
|
||||
: row.auto_schedule_config)
|
||||
: 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_name: { type: 'string' },
|
||||
banner_url: { type: ['string', 'null'] },
|
||||
cron_interval: { type: 'integer', default: 2 },
|
||||
cron_interval: { type: ['integer', 'null'] },
|
||||
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
|
||||
extract_members_from_desc: { type: 'boolean', default: false },
|
||||
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
weekly_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
},
|
||||
required: ['channel_id', 'channel_name'],
|
||||
},
|
||||
|
|
@ -196,11 +203,12 @@ export default async function youtubeBotsRoutes(fastify) {
|
|||
channel_handle,
|
||||
channel_name,
|
||||
banner_url,
|
||||
cron_interval = 2,
|
||||
cron_interval,
|
||||
title_filters,
|
||||
default_member_ids,
|
||||
extract_members_from_desc = false,
|
||||
auto_schedule_config,
|
||||
weekly_schedule_config,
|
||||
} = request.body;
|
||||
|
||||
// 중복 체크
|
||||
|
|
@ -212,21 +220,26 @@ export default async function youtubeBotsRoutes(fastify) {
|
|||
return badRequest(reply, '이미 등록된 채널입니다.');
|
||||
}
|
||||
|
||||
// weekly 모드면 cron_interval은 무시(null 저장), 아니면 기본값 2
|
||||
const finalCronInterval = weekly_schedule_config ? null : (cron_interval ?? 2);
|
||||
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO bot_youtube
|
||||
(channel_id, channel_handle, channel_name, banner_url, cron_interval,
|
||||
title_filters, default_member_ids, extract_members_from_desc, auto_schedule_config, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||
title_filters, default_member_ids, extract_members_from_desc,
|
||||
auto_schedule_config, weekly_schedule_config, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||
[
|
||||
channel_id,
|
||||
channel_handle || null,
|
||||
channel_name,
|
||||
banner_url || null,
|
||||
cron_interval,
|
||||
finalCronInterval,
|
||||
title_filters ? JSON.stringify(title_filters) : null,
|
||||
default_member_ids ? JSON.stringify(default_member_ids) : null,
|
||||
extract_members_from_desc ? 1 : 0,
|
||||
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_name: { type: 'string' },
|
||||
banner_url: { type: ['string', 'null'] },
|
||||
cron_interval: { type: 'integer' },
|
||||
cron_interval: { type: ['integer', 'null'] },
|
||||
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
|
||||
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
|
||||
extract_members_from_desc: { type: 'boolean' },
|
||||
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
weekly_schedule_config: { type: ['object', 'null'], additionalProperties: true },
|
||||
enabled: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
|
|
@ -321,6 +335,15 @@ export default async function youtubeBotsRoutes(fastify) {
|
|||
fields.push('auto_schedule_config = ?');
|
||||
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) {
|
||||
fields.push('enabled = ?');
|
||||
values.push(updates.enabled ? 1 : 0);
|
||||
|
|
|
|||
10
docs/api.md
10
docs/api.md
|
|
@ -336,10 +336,20 @@ YouTube 봇 추가
|
|||
"time": "18:00:00",
|
||||
"titleTemplate": "{channelName} {episode}화",
|
||||
"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
|
||||
YouTube 봇 수정
|
||||
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ fromis_9/
|
|||
- `concert_setlist_members` - 셋리스트-멤버 연결
|
||||
|
||||
#### 봇
|
||||
- `bot_youtube` - YouTube 봇 설정 (채널 정보, 동기화 간격, 필터 등, video_id UNIQUE)
|
||||
- `bot_youtube` - YouTube 봇 설정 (채널 정보, 동기화 간격 또는 주간 지정 시간, 필터 등, video_id UNIQUE)
|
||||
- `bot_x` - X 봇 설정 (username, 프로필, 동기화 간격, 텍스트 필터, 리트윗 포함, YouTube 추출)
|
||||
|
||||
#### 활동 로그
|
||||
|
|
|
|||
|
|
@ -272,6 +272,27 @@ queryClient.invalidateQueries();
|
|||
- 새 영상 있을 때: 1 + 새 영상 수 units
|
||||
- 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)
|
||||
| 함수 | YouTube API | 용도 |
|
||||
|------|-----------|------|
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Youtube, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-reac
|
|||
import { getMembers } from '@/api/public/members';
|
||||
import { getYouTubeBot, createYouTubeBot, updateYouTubeBot, lookupChannel } from '@/api/admin/bots';
|
||||
|
||||
// 동기화 간격 옵션
|
||||
// 동기화 간격 옵션 (분)
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 1, label: '1분' },
|
||||
{ value: 2, label: '2분' },
|
||||
|
|
@ -19,15 +19,32 @@ const INTERVAL_OPTIONS = [
|
|||
{ 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 = [
|
||||
{ value: 0, label: '일요일' },
|
||||
{ value: 1, label: '월요일' },
|
||||
{ value: 2, label: '화요일' },
|
||||
{ value: 3, label: '수요일' },
|
||||
{ value: 4, label: '목요일' },
|
||||
{ value: 5, label: '금요일' },
|
||||
{ value: 6, label: '토요일' },
|
||||
{ value: 0, label: '일요일' },
|
||||
];
|
||||
|
||||
// 시간 옵션 (00:00 ~ 23:00)
|
||||
|
|
@ -262,7 +279,12 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
|||
const [handle, setHandle] = useState('');
|
||||
const [channelInfo, setChannelInfo] = useState(null);
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [pollingMode, setPollingMode] = useState('interval'); // 'interval' | 'weekly'
|
||||
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);
|
||||
|
||||
// 예정 일정 설정
|
||||
|
|
@ -315,6 +337,26 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
|||
});
|
||||
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
|
||||
? (typeof bot.auto_schedule_config === 'string'
|
||||
? JSON.parse(bot.auto_schedule_config)
|
||||
|
|
@ -353,6 +395,11 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
|||
setHandle('');
|
||||
setChannelInfo(null);
|
||||
setInterval(2);
|
||||
setPollingMode('interval');
|
||||
setWeeklyDayOfWeek(1);
|
||||
setWeeklyStartTime('00:00');
|
||||
setWeeklyIntervalSeconds(30);
|
||||
setWeeklyDurationMinutes(30);
|
||||
setAutoScheduleEnabled(false);
|
||||
setScheduleDayOfWeek(4);
|
||||
setScheduleTime('18:00');
|
||||
|
|
@ -396,7 +443,7 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
|||
const data = {
|
||||
channel_handle: handle || null,
|
||||
channel_name: channelInfo.title,
|
||||
cron_interval: interval,
|
||||
cron_interval: pollingMode === 'interval' ? interval : null,
|
||||
title_filters: titleFilters.length > 0 ? titleFilters : null,
|
||||
default_member_ids: defaultMemberIds.length > 0 ? defaultMemberIds : null,
|
||||
extract_members_from_desc: extractMembers,
|
||||
|
|
@ -408,6 +455,14 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
|||
deadlineDayOfWeek,
|
||||
}
|
||||
: null,
|
||||
weekly_schedule_config: pollingMode === 'weekly'
|
||||
? {
|
||||
dayOfWeek: weeklyDayOfWeek,
|
||||
startTime: weeklyStartTime,
|
||||
intervalSeconds: weeklyIntervalSeconds,
|
||||
durationMinutes: weeklyDurationMinutes,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
|
|
@ -530,17 +585,95 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 동기화 간격 */}
|
||||
{/* 동기화 모드 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
동기화 간격
|
||||
동기화 방식
|
||||
</label>
|
||||
<Dropdown
|
||||
value={interval}
|
||||
options={INTERVAL_OPTIONS}
|
||||
onChange={setInterval}
|
||||
placeholder="간격 선택"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPollingMode('interval')}
|
||||
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>
|
||||
|
||||
{/* 예정 일정 자동 생성 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue