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);
});
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 ──

View file

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

View file

@ -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<number | undefined>(undefined);
const [monitoringMode, setMonitoringMode] = useState<string>('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) {
</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 */}
{detectedPlatform === 'youtube' && (
<>

View file

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