feat(S02/T02): Wired monitoringMode through channel creation (route sch…

- src/server/routes/channel.ts
- src/frontend/src/api/hooks/useChannels.ts
- src/frontend/src/components/AddChannelModal.tsx
- src/__tests__/channel.test.ts
This commit is contained in:
John Lightner 2026-03-24 20:32:13 -05:00
parent 6715c9b6fe
commit 0ef34b1d21
4 changed files with 150 additions and 3 deletions

View file

@ -260,6 +260,92 @@ describe('Channel API', () => {
expect(res.statusCode).toBe(400); 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 ── // ── GET /api/v1/channel ──

View file

@ -37,6 +37,7 @@ interface CreateChannelInput {
url: string; url: string;
checkInterval?: number; checkInterval?: number;
monitoringEnabled?: boolean; monitoringEnabled?: boolean;
monitoringMode?: string;
formatProfileId?: number; formatProfileId?: number;
grabAll?: boolean; grabAll?: boolean;
grabAllOrder?: 'newest' | 'oldest'; grabAllOrder?: 'newest' | 'oldest';

View file

@ -3,6 +3,7 @@ import { Modal } from './Modal';
import { useCreateChannel } from '../api/hooks/useChannels'; import { useCreateChannel } from '../api/hooks/useChannels';
import { usePlatformSettings } from '../api/hooks/usePlatformSettings'; import { usePlatformSettings } from '../api/hooks/usePlatformSettings';
import { useFormatProfiles } from '../api/hooks/useFormatProfiles'; import { useFormatProfiles } from '../api/hooks/useFormatProfiles';
import { apiClient } from '../api/client';
import { Loader, CheckCircle } from 'lucide-react'; import { Loader, CheckCircle } from 'lucide-react';
import type { Platform } from '@shared/types/index'; import type { Platform } from '@shared/types/index';
@ -49,6 +50,7 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [checkInterval, setCheckInterval] = useState(''); const [checkInterval, setCheckInterval] = useState('');
const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined); const [formatProfileId, setFormatProfileId] = useState<number | undefined>(undefined);
const [monitoringMode, setMonitoringMode] = useState<string>('all');
const [grabAll, setGrabAll] = useState(false); const [grabAll, setGrabAll] = useState(false);
const [grabAllOrder, setGrabAllOrder] = useState<'newest' | 'oldest'>('newest'); const [grabAllOrder, setGrabAllOrder] = useState<'newest' | 'oldest'>('newest');
@ -76,6 +78,11 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
setFormatProfileId(settings.defaultFormatProfileId); setFormatProfileId(settings.defaultFormatProfileId);
} }
// Pre-fill monitoring mode from platform defaults
if (settings.defaultMonitoringMode) {
setMonitoringMode(settings.defaultMonitoringMode);
}
// Pre-fill grab-all defaults for YouTube // Pre-fill grab-all defaults for YouTube
if (detectedPlatform === 'youtube') { if (detectedPlatform === 'youtube') {
if (settings.grabAllEnabled) { if (settings.grabAllEnabled) {
@ -95,14 +102,22 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
{ {
url: url.trim(), url: url.trim(),
checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined, checkInterval: checkInterval ? parseInt(checkInterval, 10) : undefined,
monitoringMode,
formatProfileId: formatProfileId ?? undefined, formatProfileId: formatProfileId ?? undefined,
grabAll: detectedPlatform === 'youtube' ? grabAll : undefined, grabAll: detectedPlatform === 'youtube' ? grabAll : undefined,
grabAllOrder: detectedPlatform === 'youtube' && grabAll ? grabAllOrder : undefined, grabAllOrder: detectedPlatform === 'youtube' && grabAll ? grabAllOrder : undefined,
}, },
{ {
onSuccess: () => { onSuccess: (newChannel) => {
resetForm(); resetForm();
onClose(); 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(''); setUrl('');
setCheckInterval(''); setCheckInterval('');
setFormatProfileId(undefined); setFormatProfileId(undefined);
setMonitoringMode('all');
setGrabAll(false); setGrabAll(false);
setGrabAllOrder('newest'); setGrabAllOrder('newest');
createChannel.reset(); createChannel.reset();
@ -232,6 +248,35 @@ export function AddChannelModal({ open, onClose }: AddChannelModalProps) {
</div> </div>
)} )}
{/* Monitoring Mode — shown when platform detected */}
{detectedPlatform && (
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="monitoring-mode"
style={{
display: 'block',
marginBottom: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
fontWeight: 500,
color: 'var(--text-secondary)',
}}
>
Monitoring Mode
</label>
<select
id="monitoring-mode"
value={monitoringMode}
onChange={(e) => setMonitoringMode(e.target.value)}
disabled={createChannel.isPending}
style={{ width: '100%' }}
>
<option value="all">Monitor All</option>
<option value="future">Future Only</option>
<option value="none">None</option>
</select>
</div>
)}
{/* Grab All — YouTube only */} {/* Grab All — YouTube only */}
{detectedPlatform === 'youtube' && ( {detectedPlatform === 'youtube' && (
<> <>

View file

@ -36,6 +36,7 @@ const createChannelBodySchema = {
url: { type: 'string' as const }, url: { type: 'string' as const },
checkInterval: { type: 'number' as const, minimum: 1 }, checkInterval: { type: 'number' as const, minimum: 1 },
monitoringEnabled: { type: 'boolean' as const }, monitoringEnabled: { type: 'boolean' as const },
monitoringMode: { type: 'string' as const, enum: ['all', 'future', 'existing', 'none'] },
formatProfileId: { type: 'number' as const }, formatProfileId: { type: 'number' as const },
grabAll: { type: 'boolean' as const }, grabAll: { type: 'boolean' as const },
grabAllOrder: { type: 'string' as const, enum: ['newest', 'oldest'] }, grabAllOrder: { type: 'string' as const, enum: ['newest', 'oldest'] },
@ -89,6 +90,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
url: string; url: string;
checkInterval?: number; checkInterval?: number;
monitoringEnabled?: boolean; monitoringEnabled?: boolean;
monitoringMode?: MonitoringMode;
formatProfileId?: number; formatProfileId?: number;
grabAll?: boolean; grabAll?: boolean;
grabAllOrder?: 'newest' | 'oldest'; grabAllOrder?: 'newest' | 'oldest';
@ -99,7 +101,7 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
schema: { body: createChannelBodySchema }, schema: { body: createChannelBodySchema },
}, },
async (request, reply) => { 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 // Validate URL against registered platforms
const match = registry.getForUrl(url); const match = registry.getForUrl(url);
@ -150,13 +152,26 @@ export async function channelRoutes(fastify: FastifyInstance): Promise<void> {
10 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 // Insert channel
const channel = await createChannel(fastify.db, { const channel = await createChannel(fastify.db, {
name: metadata.name, name: metadata.name,
platform: metadata.platform, platform: metadata.platform,
platformId: metadata.platformId, platformId: metadata.platformId,
url: metadata.url, url: metadata.url,
monitoringEnabled: monitoringEnabled ?? true, monitoringEnabled: resolvedEnabled,
monitoringMode: resolvedMode,
checkInterval: checkInterval ?? defaultInterval, checkInterval: checkInterval ?? defaultInterval,
imageUrl: metadata.imageUrl, imageUrl: metadata.imageUrl,
metadata: null, metadata: null,