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:
caadiq 2026-04-22 20:56:54 +09:00
parent 39bb6f77f9
commit f2a15e07d6
7 changed files with 371 additions and 122 deletions

View file

@ -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,

View file

@ -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' });

View file

@ -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);

View file

@ -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 봇 수정

View file

@ -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 추출)
#### 활동 로그

View file

@ -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 | 용도 |
|------|-----------|------|

View file

@ -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>
{/* 예정 일정 자동 생성 */}