diff --git a/src/__tests__/channel.test.ts b/src/__tests__/channel.test.ts index 33d77af..c6e12a8 100644 --- a/src/__tests__/channel.test.ts +++ b/src/__tests__/channel.test.ts @@ -260,6 +260,92 @@ describe('Channel API', () => { expect(res.statusCode).toBe(400); }); + + it('creates channel with monitoringMode=future and monitoringEnabled=true', async () => { + execYtDlpMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + channel: 'Future Only Channel', + channel_id: 'UC_FUTURE_001', + channel_url: 'https://www.youtube.com/@FutureOnly', + uploader: 'Future Only Channel', + thumbnails: [{ url: 'https://yt.com/future_thumb.jpg' }], + }), + stderr: '', + exitCode: 0, + }); + + const res = await server.inject({ + method: 'POST', + url: '/api/v1/channel', + headers: { 'x-api-key': apiKey }, + payload: { + url: 'https://www.youtube.com/@FutureOnly', + monitoringMode: 'future', + }, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.monitoringMode).toBe('future'); + expect(body.monitoringEnabled).toBe(true); + }); + + it('creates channel with monitoringMode=none and monitoringEnabled=false', async () => { + execYtDlpMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + channel: 'No Monitor Channel', + channel_id: 'UC_NOMONITOR_001', + channel_url: 'https://www.youtube.com/@NoMonitor', + uploader: 'No Monitor Channel', + thumbnails: [{ url: 'https://yt.com/nomonitor_thumb.jpg' }], + }), + stderr: '', + exitCode: 0, + }); + + const res = await server.inject({ + method: 'POST', + url: '/api/v1/channel', + headers: { 'x-api-key': apiKey }, + payload: { + url: 'https://www.youtube.com/@NoMonitor', + monitoringMode: 'none', + }, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.monitoringMode).toBe('none'); + expect(body.monitoringEnabled).toBe(false); + }); + + it('defaults monitoringMode to all when not specified', async () => { + execYtDlpMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + channel: 'Default Mode Channel', + channel_id: 'UC_DEFAULTMODE_001', + channel_url: 'https://www.youtube.com/@DefaultMode', + uploader: 'Default Mode Channel', + thumbnails: [{ url: 'https://yt.com/defaultmode_thumb.jpg' }], + }), + stderr: '', + exitCode: 0, + }); + + const res = await server.inject({ + method: 'POST', + url: '/api/v1/channel', + headers: { 'x-api-key': apiKey }, + payload: { + url: 'https://www.youtube.com/@DefaultMode', + }, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.monitoringMode).toBe('all'); + expect(body.monitoringEnabled).toBe(true); + }); }); // ── GET /api/v1/channel ── diff --git a/src/frontend/src/api/hooks/useChannels.ts b/src/frontend/src/api/hooks/useChannels.ts index 4d8dac6..4bb250e 100644 --- a/src/frontend/src/api/hooks/useChannels.ts +++ b/src/frontend/src/api/hooks/useChannels.ts @@ -37,6 +37,7 @@ interface CreateChannelInput { url: string; checkInterval?: number; monitoringEnabled?: boolean; + monitoringMode?: string; formatProfileId?: number; grabAll?: boolean; grabAllOrder?: 'newest' | 'oldest'; diff --git a/src/frontend/src/components/AddChannelModal.tsx b/src/frontend/src/components/AddChannelModal.tsx index b8cb8e4..7ffc163 100644 --- a/src/frontend/src/components/AddChannelModal.tsx +++ b/src/frontend/src/components/AddChannelModal.tsx @@ -3,6 +3,7 @@ import { Modal } from './Modal'; import { useCreateChannel } from '../api/hooks/useChannels'; import { usePlatformSettings } from '../api/hooks/usePlatformSettings'; import { useFormatProfiles } from '../api/hooks/useFormatProfiles'; +import { apiClient } from '../api/client'; import { Loader, CheckCircle } from 'lucide-react'; import type { Platform } from '@shared/types/index'; @@ -49,6 +50,7 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) { const [url, setUrl] = useState(''); const [checkInterval, setCheckInterval] = useState(''); const [formatProfileId, setFormatProfileId] = useState(undefined); + const [monitoringMode, setMonitoringMode] = useState('all'); const [grabAll, setGrabAll] = useState(false); const [grabAllOrder, setGrabAllOrder] = useState<'newest' | 'oldest'>('newest'); @@ -76,6 +78,11 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) { setFormatProfileId(settings.defaultFormatProfileId); } + // Pre-fill monitoring mode from platform defaults + if (settings.defaultMonitoringMode) { + setMonitoringMode(settings.defaultMonitoringMode); + } + // Pre-fill grab-all defaults for YouTube if (detectedPlatform === 'youtube') { if (settings.grabAllEnabled) { @@ -95,14 +102,22 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) { { url: url.trim(), checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined, + monitoringMode, formatProfileId: formatProfileId ?? undefined, grabAll: detectedPlatform === 'youtube' ? grabAll : undefined, grabAllOrder: detectedPlatform === 'youtube' && grabAll ? grabAllOrder : undefined, }, { - onSuccess: () => { + onSuccess: (newChannel) => { resetForm(); onClose(); + // Fire-and-forget initial scan so content appears immediately + apiClient + .post(`/api/v1/channel/${newChannel.id}/scan`) + .catch(() => { + // Auto-scan failure is non-critical — channel was created successfully + console.warn(`[AddChannel] Auto-scan failed for channel ${newChannel.id}`); + }); }, }, ); @@ -112,6 +127,7 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) { setUrl(''); setCheckInterval(''); setFormatProfileId(undefined); + setMonitoringMode('all'); setGrabAll(false); setGrabAllOrder('newest'); createChannel.reset(); @@ -232,6 +248,35 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) { )} + {/* Monitoring Mode — shown when platform detected */} + {detectedPlatform && ( +
+ + +
+ )} + {/* Grab All — YouTube only */} {detectedPlatform === 'youtube' && ( <> diff --git a/src/server/routes/channel.ts b/src/server/routes/channel.ts index 99f47bc..9e19c25 100644 --- a/src/server/routes/channel.ts +++ b/src/server/routes/channel.ts @@ -36,6 +36,7 @@ const createChannelBodySchema = { url: { type: 'string' as const }, checkInterval: { type: 'number' as const, minimum: 1 }, monitoringEnabled: { type: 'boolean' as const }, + monitoringMode: { type: 'string' as const, enum: ['all', 'future', 'existing', 'none'] }, formatProfileId: { type: 'number' as const }, grabAll: { type: 'boolean' as const }, grabAllOrder: { type: 'string' as const, enum: ['newest', 'oldest'] }, @@ -89,6 +90,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise { url: string; checkInterval?: number; monitoringEnabled?: boolean; + monitoringMode?: MonitoringMode; formatProfileId?: number; grabAll?: boolean; grabAllOrder?: 'newest' | 'oldest'; @@ -99,7 +101,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise { schema: { body: createChannelBodySchema }, }, async (request, reply) => { - const { url, checkInterval, monitoringEnabled, formatProfileId, grabAll, grabAllOrder } = request.body; + const { url, checkInterval, monitoringEnabled, monitoringMode, formatProfileId, grabAll, grabAllOrder } = request.body; // Validate URL against registered platforms const match = registry.getForUrl(url); @@ -150,13 +152,26 @@ export async function channelRoutes(fastify: FastifyInstance): Promise { 10 ); + // Derive monitoringEnabled from monitoringMode when provided; + // preserve backward compat when only monitoringEnabled is sent + const resolvedMode = monitoringMode ?? 'all'; + const resolvedEnabled = monitoringMode + ? monitoringMode !== 'none' + : (monitoringEnabled ?? true); + + request.log.info( + { resolvedMode, resolvedEnabled, platform: metadata.platform }, + '[channel] Creating channel with monitoring mode' + ); + // Insert channel const channel = await createChannel(fastify.db, { name: metadata.name, platform: metadata.platform, platformId: metadata.platformId, url: metadata.url, - monitoringEnabled: monitoringEnabled ?? true, + monitoringEnabled: resolvedEnabled, + monitoringMode: resolvedMode, checkInterval: checkInterval ?? defaultInterval, imageUrl: metadata.imageUrl, metadata: null,