From f2a15e07d6f65e172dee7af570b465e1da771237 Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 22 Apr 2026 20:56:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(youtube-bot):=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95=20=EC=8B=9C=EA=B0=84=20=ED=8F=B4=EB=A7=81=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/sql/bot_youtube.sql | 7 +- backend/src/plugins/scheduler.js | 259 +++++++++++------- backend/src/routes/admin/youtube-bots.js | 37 ++- docs/api.md | 10 + docs/architecture.md | 2 +- docs/development.md | 21 ++ .../pc/admin/bot/YouTubeBotDialog.jsx | 157 ++++++++++- 7 files changed, 371 insertions(+), 122 deletions(-) diff --git a/backend/sql/bot_youtube.sql b/backend/sql/bot_youtube.sql index c8469f6..54e0c68 100644 --- a/backend/sql/bot_youtube.sql +++ b/backend/sql/bot_youtube.sql @@ -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, diff --git a/backend/src/plugins/scheduler.js b/backend/src/plugins/scheduler.js index 2dbad9d..b65b293 100644 --- a/backend/src/plugins/scheduler.js +++ b/backend/src/plugins/scheduler.js @@ -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' }); diff --git a/backend/src/routes/admin/youtube-bots.js b/backend/src/routes/admin/youtube-bots.js index 08c4e5b..7498d76 100644 --- a/backend/src/routes/admin/youtube-bots.js +++ b/backend/src/routes/admin/youtube-bots.js @@ -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); diff --git a/docs/api.md b/docs/api.md index f60b9a9..78f9efc 100644 --- a/docs/api.md +++ b/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 봇 수정 diff --git a/docs/architecture.md b/docs/architecture.md index 7d69c6e..19827ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 추출) #### 활동 로그 diff --git a/docs/development.md b/docs/development.md index 6f5bb91..6c2b6e9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 | 용도 | |------|-----------|------| diff --git a/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx b/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx index e67e588..83ac05c 100644 --- a/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx +++ b/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx @@ -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 }) { )} - {/* 동기화 간격 */} + {/* 동기화 모드 */}
- +
+ + +
+ + {pollingMode === 'interval' ? ( +
+ +

+ 선택한 간격으로 계속 체크합니다 +

+
+ ) : ( +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+

+ 지정된 요일·시각부터 이 간격으로 폴링합니다. 새 영상 발견 시 즉시 종료하며, 최대 지속시간 초과 시에도 종료합니다. +

+
+ )}
{/* 예정 일정 자동 생성 */}